diff --git a/frontend/src/api/a11y.ts b/frontend/src/api/a11y.ts index b5ef317..1dab584 100644 --- a/frontend/src/api/a11y.ts +++ b/frontend/src/api/a11y.ts @@ -98,7 +98,22 @@ export const 기본접근성: 접근성 = { staffAssistancePreferred: false, }; -export const 기본도움설정: 도움설정 = { ...기본접근성, voiceGuide: false, language: "ko-KR" }; +/** + * 소리로 듣고 답하기를 **처음부터 켜 둔다.** + * + * 여태 꺼짐이었다. 그런데 이 스위치는 화면을 못 보는 분에게 가장 필요한 + * 것인데, 켜려면 화면에서 그 스위치를 찾아 눌러야 했다 — **읽어 줘야 읽을 + * 수 있는 사람은 켜러 갈 수가 없다.** 첫 화면으로 옮겨 놓은 것도 그래서였고, + * 이번에 그 마지막 한 걸음까지 없앤다. + * + * 맞바꾸는 것이 있다. 눈으로 보는 분에게는 열자마자 화면을 읽는 소리가 난다. + * 그 대신 끄는 길은 늘 첫 화면 맨 위에 있고, 한 번 끄면 그 설정이 남는다. + * 소리를 못 내는 기기에서는 스위치 자체가 안 보이므로 달라지는 것이 없다. + * + * 마이크가 저절로 열리지는 않는다. 브라우저가 이미 허용해 둔 자리에서만 + * 부르는 말을 기다리고(api/wake.ts), 그 전에는 아무것도 듣지 않는다. + */ +export const 기본도움설정: 도움설정 = { ...기본접근성, voiceGuide: true, language: "ko-KR" }; let 값: 도움설정 = { ...기본도움설정 }; const 듣는이 = new Set<() => void>(); diff --git a/frontend/src/api/backend.test.ts b/frontend/src/api/backend.test.ts index 4650972..9e400f3 100644 --- a/frontend/src/api/backend.test.ts +++ b/frontend/src/api/backend.test.ts @@ -264,6 +264,41 @@ describe("일회용 연결 (팀 #108)", () => { .then(() => null, (err: KioBridgeError) => err); expect(e?.code).toBe("CLAIM_EXPIRED"); }); + + it("취소한 뒤 다른 주문표를 고르면 매핑에서 먼저 막는다 (팀 #146)", async () => { + /* + * 서버는 거절도 승인과 같은 경로로 처리해 pairing 을 폐기한다. 그래서 + * 취소하고 나온 뒤 다른 주문표로 들어가면 죽은 값으로 bind 를 시도하고, + * 서버의 PAIRING_NOT_FOUND 가 "연결 정보를 찾을 수 없습니다" 라는 + * 개발자 말로 화면에 그대로 떴다. + * + * 승인 경로에는 이 검사가 있었는데 매핑 경로에는 없었다. 화면은 매핑부터 + * 부르므로, 정작 사용자가 먼저 닿는 쪽이 안 막혀 있었다. + */ + const api = createApi(가짜백엔드()); + await api.claimPairing("kb"); + await api.requestMapping("s1", "p1"); + await api.reject({ pairingId: "s1", sheetId: "p1" }); + + const e = await api + .requestMapping("s1", "p2") + .then(() => null, (err: KioBridgeError) => err); + expect(e?.code).toBe("CLAIM_EXPIRED"); + // 되돌릴 수 있는 오류여야 화면이 'QR 다시 찍기' 로 안내한다. + expect(e?.recoverable).toBe(true); + // 개발자 말이 새어 나가면 안 된다. + expect(e?.message).not.toContain("PAIRING_NOT_FOUND"); + expect(e?.message).toContain("QR"); + }); + + it("취소하지 않았으면 다른 주문표로 계속 갈 수 있다", async () => { + // 위 시험이 '언제나 막는다' 로 헛통과하지 않게 지킨다. 연결이 살아 있는 + // 동안 주문표를 바꿔 보는 것은 정상 흐름이다. + const api = createApi(가짜백엔드()); + await api.claimPairing("kb"); + await api.requestMapping("s1", "p1"); + await expect(api.requestMapping("s1", "p2")).resolves.toBeTruthy(); + }); }); describe("주문 입력을 pairing 에 고정하지 못하면 승인까지 가지 않는다 (팀 #108)", () => { diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index 7ab8f7f..2f3ce6f 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -285,6 +285,23 @@ export function createApi( }, async requestMapping(pairingId, sheetId) { + /* + * 이미 다 쓴 연결이면 여기서 먼저 접는다(팀 #146). + * + * 승인이든 거절이든 서버는 끝난 pairing 을 폐기한다(OrchestratorController + * 의 finally 에서 close). 그래서 취소하고 나온 뒤 다른 주문표로 들어가면 + * 죽은 값으로 bind 를 시도하게 되고, 서버가 돌려주는 PAIRING_NOT_FOUND 가 + * **"연결 정보를 찾을 수 없습니다"** 라는 개발자 말로 화면에 그대로 떴다. + * 무엇이 잘못됐는지도, 무엇을 해야 하는지도 알 수 없는 안내다. + * + * 승인 경로(approve)에는 이 검사가 이미 있었는데 매핑 경로에는 없었다. + * 화면은 매핑부터 부르므로, 정작 사용자가 먼저 닿는 쪽이 안 막혀 있었다. + * + * 되돌릴 수 있는 오류로 던진다(true) — 화면이 QR 다시 찍기로 안내한다. + */ + if (연결끝남.has(pairingId)) { + throw new KioBridgeError("CLAIM_EXPIRED", "이 연결은 이미 사용했어요. QR 을 다시 찍어 주세요", true); + } // 서버에 주문표 저장소가 없으면 내용을 함께 보내야 한다. 있으면 id 만으로 충분하다. const profile = getSheet?.(sheetId); const env = 환경.get(pairingId) ?? environmentId; diff --git a/frontend/src/api/listen.ts b/frontend/src/api/listen.ts index 357c041..20ee2fb 100644 --- a/frontend/src/api/listen.ts +++ b/frontend/src/api/listen.ts @@ -39,6 +39,8 @@ * speech.ts 의 `소리를낼수있나` 와 같은 판단이다. */ +import { 말끝지켜보기 } from "@/api/vad"; + export type 못들은이유 = "권한없음" | "소리없음" | "안됨"; /** 이 기기에서 마이크를 녹음할 수 있는가. 화면이 단추를 내밀지 말지 이걸로 정한다. */ @@ -113,6 +115,16 @@ const 서버로보내기 = async ( export const 들어보기 = ( 언어: string, 받기: (결과: { 들은말: string } | { 못들은이유: 못들은이유 }) => void, + /** + * `스스로끝내기` — 말이 끝나면 '그만 듣기' 없이 알아서 보낸다(api/vad.ts). + * + * 끄면 지금까지와 똑같다(사람이 눌러야 끝난다). 켜면 소리의 크기만 기기 + * 안에서 지켜보다가, 말이 있었고 그 뒤 조용해진 순간 녹음을 끝낸다. + * + * 지켜보기가 안 되는 브라우저에서도 아무것도 깨지지 않는다 — 그때는 vad 가 + * 조용히 물러나고, 사람이 누르는 길과 최대 녹음 시간이 그대로 남는다. + */ + { 스스로끝내기 = false }: { 스스로끝내기?: boolean } = {}, ): { 그만두기: (보내기?: boolean) => void } => { if (!들을수있나()) { 받기({ 못들은이유: "안됨" }); @@ -133,8 +145,11 @@ export const 들어보기 = ( let 보낼지 = true; const 조각들: BlobPart[] = []; let 최대시간표: ReturnType | undefined; + let 지켜보기: { 그만보기: () => void } | null = null; const 마이크끄기 = () => { + 지켜보기?.그만보기(); + 지켜보기 = null; 스트림?.getTracks().forEach((트랙) => 트랙.stop()); }; @@ -176,10 +191,33 @@ export const 들어보기 = ( * 안 누르고 계속 말해도 무한정 녹음하지 않는다. 15초면 접근성 설정의 * 예/아니오 하나, 주문표 한 축을 답하기에 넉넉하고, Whisper 호출 하나가 * 지나치게 길어지는 것도 막는다. + * + * 스스로끝내기를 켜도 이 그물은 그대로 둔다 — 지켜보기가 못 도는 기기나 + * 소리가 계속 나는 곳(식당 앞)에서 끝을 못 잡을 수 있다. */ 최대시간표 = setTimeout(() => { try { 레코더?.stop(); } catch { /* 이미 끝났다 */ } }, 15000); + + if (!스스로끝내기) return; + 지켜보기 = 말끝지켜보기(스트림, { + // 말이 끝났다. 사람이 '그만 듣기' 를 누른 것과 똑같이 처리한다 — + // 지금까지 녹음한 것을 보낸다. + 말이끝나면: () => { + try { 레코더?.stop(); } catch { /* 이미 끝났다 */ } + }, + /* + * 기다리는 동안 아무 말도 없었다. 녹음을 버리고 접는다. + * + * 보내면 Whisper 가 빈 소리를 받아 아무 글도 못 내놓고, 사용자는 그 + * 왕복 시간만큼 '인식 중…' 을 보고 있다가 같은 안내를 듣는다. 여기서 + * 접으면 바로 "잘 안 들렸어요" 로 간다. + */ + 아무말도없으면: () => { + 보낼지 = false; + try { 레코더?.stop(); } catch { /* 이미 끝났다 */ } + }, + }); })(); return { diff --git a/frontend/src/api/session.test.ts b/frontend/src/api/session.test.ts index ad3233c..486ced9 100644 --- a/frontend/src/api/session.test.ts +++ b/frontend/src/api/session.test.ts @@ -422,6 +422,26 @@ describe("남길 것이 없으면 아무것도 안 쓴다", () => { expect(이어쓰기.읽기()!.a11y.language).toBe("en-US"); }); + it("기본 켜짐인 설정을 안 건드린 것도 남길 것으로 세지 않는다", () => { + /* + * '건드렸다' 는 기본과 다르다는 뜻이지 '켜져 있다' 는 뜻이 아니다. + * + * voiceGuide 가 기본 켜짐이 되면서 이 자리가 깨졌다 — 값이 true 인지로 + * 보고 있었던 탓에, 아무것도 안 한 사람의 빈 이용이 전부 저장됐다. + * language 때와 같은 원인이라(기본값이 falsy 하다는 가정) 예외를 더하는 + * 대신 기준을 바꿨다. + */ + expect(기본도움설정.voiceGuide).toBe(true); + 이어쓰기.쓰기({ ...빈값, a11y: { ...기본도움설정 } }); + expect(저장소.length).toBe(0); + }); + + it("기본 켜짐인 것을 끄면 그 선택은 남긴다", () => { + // 끈 사람은 건드린 것이다. 다음에 열었을 때 다시 켜져 있으면 안 된다. + 이어쓰기.쓰기({ ...빈값, a11y: { ...기본도움설정, voiceGuide: false } }); + expect(이어쓰기.읽기()!.a11y.voiceGuide).toBe(false); + }); + it("언어가 기본값이면 남길 것으로 세지 않는다", () => { // "ko-KR" 은 truthy 라, 값이 있는지만 보면 아무것도 안 건드린 사람도 // 저장 대상이 된다. 켠 것과 고른 것을 나눠 봐야 한다. diff --git a/frontend/src/api/session.ts b/frontend/src/api/session.ts index b7a51bc..c5869c0 100644 --- a/frontend/src/api/session.ts +++ b/frontend/src/api/session.ts @@ -317,15 +317,20 @@ const 남길것이있나 = (v: 이어쓸것): boolean => /** * 도움 설정을 하나라도 기본값에서 바꿨는가. * - * Object.values(...).some(Boolean) 로 훑던 것을 칸별로 본다. language 가 들어오면서 - * 그 방식이 깨졌다 — 기본값 "ko-KR" 이 truthy 라, 아무것도 안 건드린 사람도 - * '남길 것이 있다' 가 되어 빈 이용이 전부 저장됐다. + * **기본값과 다른지만 본다.** 값이 true 인지로 보면 안 된다. * - * 켠 것과 고른 것을 나눠 본다. 켜는 값은 true 인지, 고르는 값은 기본과 다른지. + * 이 자리는 두 번 깨졌고 원인이 같았다 — 기본값이 falsy 라는 가정이다. + * 처음에는 Object.values(...).some(Boolean) 이었는데 language 가 들어오면서 + * 깨졌고("ko-KR" 이 truthy 라 아무것도 안 건드린 사람이 전부 저장됐다), + * 그것을 language 만 예외로 두어 막았다. 그 뒤 voiceGuide 가 기본 켜짐이 되자 + * 같은 일이 다시 일어났다. + * + * 예외를 하나씩 더하는 대신 기준을 바꾼다. '건드렸다' 는 **기본과 다르다** 는 + * 뜻이지 '켜져 있다' 는 뜻이 아니다 — 기본 켜짐인 것을 끈 사람도 건드린 것이고, + * 그 선택은 남겨 두어야 한다. */ const 도움설정을건드렸나 = (a: 도움설정): boolean => - (Object.keys(기본도움설정) as (keyof 도움설정)[]).some((칸) => - 칸 === "language" ? a.language !== 기본도움설정.language : a[칸] === true); + (Object.keys(기본도움설정) as (keyof 도움설정)[]).some((칸) => a[칸] !== 기본도움설정[칸]); export const 이어쓰기 = { /** diff --git a/frontend/src/api/speech.ts b/frontend/src/api/speech.ts index 4c32d3a..d5c5b2e 100644 --- a/frontend/src/api/speech.ts +++ b/frontend/src/api/speech.ts @@ -189,6 +189,51 @@ export const 화면글 = (뿌리: HTMLElement, { 바뀌는것빼고 = false } = return 줄; }; +/** + * 읽고 있는 것이 다 끝날 때까지 기다린다. + * + * 다음 질문을 스스로 시작할 때 꼭 필요하다. 안 기다리면 마이크가 안내를 읽는 + * 도중에 열리고, 두 가지가 한꺼번에 어긋난다. + * + * ① 듣기시작() 이 그만읽기() 를 부르므로 안내가 중간에서 잘린다. 화면을 못 + * 보는 분은 무엇을 묻는지 못 들은 채 답해야 한다. + * ② 자르지 않더라도 스피커 소리를 마이크가 그대로 주워듣는다. 그 소리가 + * 녹음에 실려 인식을 망친다(읽어주기 주석의 같은 얘기). + * + * 최대 기다릴 시간을 둔다. 읽기가 끝났는데 speaking 이 안 내려가는 브라우저가 + * 있고, 거기서 영원히 멈추면 사용자는 아무 일도 안 일어나는 화면을 본다. + * + * ── '아직 안 읽음' 과 '다 읽음' 은 겉보기가 같다 ──────────────────────────── + * + * 둘 다 speaking·pending 이 false 다. 그래서 읽기가 걸리기 **전에** 물어보면 + * 곧바로 "다 읽었다" 고 답해 버린다 — 부르는 쪽은 마이크를 열고, 그 직후 안내 + * 읽기가 시작됐다가 듣기시작() 의 그만읽기() 에 잘린다. 화면을 못 보는 분은 + * 무엇을 묻는지 못 들은 채 답해야 한다. + * + * `시작기다림` 을 주면 그 동안은 접지 않는다. 한 번이라도 읽는 것을 본 뒤에는 + * 바로 끝난다 — 짧은 안내를 읽는 동안 헛되이 기다리지 않는다. + */ +export const 다읽을때까지 = (최대 = 8000, { 시작기다림 = 0 } = {}): Promise => + new Promise((끝) => { + if (!소리를낼수있나()) { 끝(); return; } + const 시작 = Date.now(); + let 읽기를봤나 = false; + let 시계: ReturnType; + const 보기 = () => { + let 읽는중 = false; + try { + 읽는중 = globalThis.speechSynthesis.speaking || globalThis.speechSynthesis.pending; + } catch { /* 못 물어보면 안 읽는 것으로 본다 */ } + if (읽는중) 읽기를봤나 = true; + const 지난 = Date.now() - 시작; + // 아직 시작도 안 했을 수 있다. 시작기다림 이 지나기 전에는 안 접는다. + const 접어도되나 = 읽기를봤나 || 지난 >= 시작기다림; + if ((!읽는중 && 접어도되나) || 지난 >= 최대) { clearInterval(시계); 끝(); } + }; + 시계 = setInterval(보기, 120); + 보기(); + }); + /** 읽던 것을 멈춘다. 스위치를 끄거나 화면을 떠날 때 부른다. */ export const 그만읽기 = (): void => { try { diff --git a/frontend/src/api/spicy.test.ts b/frontend/src/api/spicy.test.ts index ab02688..ddb4df1 100644 --- a/frontend/src/api/spicy.test.ts +++ b/frontend/src/api/spicy.test.ts @@ -5,10 +5,10 @@ import { 맵기물어보기 } from "./spicy"; * 이 파일이 지키는 것. * * ① 서버가 잡아 준 말을 화면 보기 이름으로 옮긴다 — enum 이 사용자에게 안 보인다. - * ② **부정을 서버보다 우리가 더 믿는다.** 서버는 임베딩 유사도로 고르는데 그 - * 방식은 부정을 못 읽는다. 실서버로 재 봤다 — - * "안 매운 거" → confident=true, HOT - * 매운 것을 못 드시는 분이 그렇게 말하면 매운맛이 들어간다. ②가 그걸 막는다. + * ② **부정은 서버가 읽는다.** 예전에는 우리 부정어 표로 서버 답을 되거르는 + * 겹이 있었다(서버가 "안 매운 거" 를 confident=true HOT 으로 주던 때). + * 팀 #138 이 서버에서 고쳤고, 배포본으로 확인한 뒤 그 겹을 걷었다. + * 아래 "부정은 이제 서버가 읽는다" 가 서버가 계속 그 일을 하는지 붙잡는다. * ③ 실패하면 조용히 물러난다. 이 경로가 없어도 앱은 손으로 고르기로 돌아간다. */ @@ -54,9 +54,36 @@ describe("서버가 잡아 준 맵기를 화면 이름으로 옮긴다", () => { expect(await 맵기물어보기("얼큰한맛")).toEqual({ 되물을것: ["보통맛"] }); }); - it("모르는 값이 없으면 하나 남은 후보는 그대로 쓴다", async () => { + it("확신 못 한 답은 후보가 하나여도 되묻는다", async () => { + /* + * 아래 본문은 **지어낸 것이다.** 지금 서버로 같은 말을 보내면 후보를 둘 준다 + * (2026-08-15, #138 배포 뒤 실측): + * + * "하나도 안 맵게" → confident=false, candidates=["MILD","MEDIUM"] + * + * 그래도 이 시험을 두는 이유는, 규칙이 **후보 개수가 아니라 confident**에 + * 걸려 있기 때문이다. 계약상 서버는 확신하지 못하면서 후보를 하나만 줄 수 + * 있고, #138 전에는 실제로 그랬다(그때 "하나도 안 맵게" 는 + * candidates=["NO_PREFERENCE"] 하나였다). 그때 우리는 그것을 자동으로 + * 골랐다 — 맵기를 못 드셔서 그렇게 말한 분의 주문이 물어본 적도 없이 + * '상관없음' 으로 넘어갔다. + * + * 서버가 되물을 문장(clarificationQuestion)까지 만들어 보낸다는 것은, + * 그쪽도 묻고 싶다는 뜻이다. 그걸 우리가 대신 고르지 않는다. + */ + 붙이기({ confident: false, matchedLevel: null, candidates: ["NO_PREFERENCE"] }); + expect(await 맵기물어보기("하나도 안 맵게")).toEqual({ 되물을것: ["상관없음"] }); + }); + + it("지금 서버가 실제로 주는 부정문 응답도 되묻기로 간다", async () => { + // 위가 지어낸 것이라, 실측 본문 하나를 나란히 둔다(#138 배포 뒤). + 붙이기({ confident: false, matchedLevel: null, candidates: ["MILD", "MEDIUM"] }); + expect(await 맵기물어보기("안 매운 거")).toEqual({ 되물을것: ["순한맛", "보통맛"] }); + }); + + it("확신한 답은 예전처럼 그대로 확정한다", async () => { // 위 시험이 '언제나 되묻는다' 로 헛통과하지 않도록 지킨다. - 붙이기({ confident: false, matchedLevel: null, candidates: ["MEDIUM"] }); + 붙이기({ confident: true, matchedLevel: "MEDIUM", candidates: ["MEDIUM"] }); expect(await 맵기물어보기("얼큰한맛")).toEqual({ 고른값: "보통맛" }); }); }); @@ -77,31 +104,36 @@ describe("개인정보처럼 보이는 말은 보내지 않는다", () => { }); }); -describe("부정은 서버보다 우리 표를 믿는다", () => { - it("확정이어도 아니라고 말한 값은 안 받는다", async () => { - /* - * 실서버 실측이다. "안 매운 거" 를 보내면 confident=true 로 HOT 이 온다 — - * "안 매운" 안에 "매운" 이 들어 있어서 매운맛 앵커와 가깝기 때문이다. - * - * 그대로 넣으면 매운 것을 못 드시는 분의 주문에 매운맛이 들어간다. - * 되묻지도 않는다(confident=true). 우리 표는 이 문제를 이미 알고 있다. - */ - 붙이기({ confident: true, matchedLevel: "HOT", candidates: ["HOT", "MILD"] }); +describe("부정은 이제 서버가 읽는다", () => { + /* + * 여기 있던 세 시험은 우리 부정어 표가 서버 답을 되거르는 것을 지키던 것이다. + * 서버가 부정을 못 읽던 때의 방어였고, 팀 #138 이 서버에서 고친 뒤 걷어냈다. + * + * **이 시험들이 서버 회귀를 잡아 주지는 않는다.** fetch 를 흉내 내므로 서버가 + * 무엇을 답하든 여기는 초록이다. 지키는 것은 '서버가 이렇게 답하면 우리는 + * 이렇게 옮긴다' 는 우리 쪽 약속뿐이다 — 되거르는 겹이 없으니, 서버 답이 + * 곧 사용자가 보는 것이다. + * + * 서버가 되돌아갔는지는 실서버에 물어봐야만 안다: + * + * curl -s -X POST https://api.hyunwoocha.site/internal/spicy-level/match \ + * -H 'content-type: application/json' -d '{"text":"안 매운 거"}' + * # confident 가 다시 true/HOT 이면 이 파일이 아니라 서버가 문제다. + * + * 아래 본문은 #138 이 들어간 뒤 실서버에서 받은 모양이다. + */ + it("부정어가 들어간 말은 서버가 되물으라고 하고, 우리는 그대로 옮긴다", async () => { + 붙이기({ confident: false, matchedLevel: null, candidates: ["MILD", "MEDIUM"] }); const r = await 맵기물어보기("안 매운 거"); + // 예전에 이 자리에서 매운맛이 나왔다. 그것이 다시 나오면 안 된다. expect(r).not.toEqual({ 고른값: "매운맛" }); - // 남은 후보가 있으면 그것으로 되묻는다. 무엇을 원하는지는 아직 모르기 때문이다. - expect(r).toEqual({ 되물을것: ["순한맛"] }); - }); - - it("후보에서도 아니라고 한 값을 빼고 되묻는다", async () => { - 붙이기({ confident: false, matchedLevel: null, candidates: ["HOT", "MEDIUM", "MILD"] }); - expect(await 맵기물어보기("안 맵게")).toEqual({ 되물을것: ["보통맛", "순한맛"] }); + expect(r).toEqual({ 되물을것: ["순한맛", "보통맛"] }); }); - it("아니라고 한 것만 남으면 못 고른 것으로 둔다", async () => { - // 짐작해서 넣지 않는다. 화면이 "못 골랐어요" 로 가고 사람이 손으로 짚는다. + it("부정어가 없는 말은 예전처럼 확정된다", async () => { + // 부정어 필터가 정상 표현까지 잡아 버리면 이 시험이 깨진다(서버 쪽 회귀). 붙이기({ confident: true, matchedLevel: "HOT", candidates: ["HOT"] }); - expect(await 맵기물어보기("안 매운 걸로")).toEqual({ 못함: true }); + expect(await 맵기물어보기("매운 거")).toEqual({ 고른값: "매운맛" }); }); }); diff --git a/frontend/src/api/spicy.ts b/frontend/src/api/spicy.ts index dc358ae..e416e6f 100644 --- a/frontend/src/api/spicy.ts +++ b/frontend/src/api/spicy.ts @@ -1,4 +1,3 @@ -import { 아니라고했나 } from "@/api/voice"; import { 개인정보같은글 } from "@/api/account"; /** @@ -13,18 +12,19 @@ import { 개인정보같은글 } from "@/api/account"; * * 서버는 앵커 표현과의 임베딩 유사도로 고른다. 표에 없던 말도 잡는다. * - * ── 그런데 서버 답을 그대로 믿지 않는다 ────────────────────────────────────── + * ── 부정은 서버가 읽는다 ──────────────────────────────────────────────────── * - * 유사도는 **부정을 못 읽는다.** "안 매운 거" 안에는 "매운" 이 들어 있어서 - * 매운맛 앵커와 가깝다. 실서버로 재 봤다: + * 한동안 여기에 겹이 하나 더 있었다. 유사도가 부정을 못 읽던 때 — + * "안 매운 거" 안에 "매운" 이 들어 있어 매운맛 앵커와 가깝다고 봤다(실측: + * confident=true, HOT) — 우리 쪽 부정 표로 서버 답을 다시 거르던 방어다. * - * "안 매운 거" confident=true HOT ← 정반대인데 되묻지도 않는다 - * "안매워요" confident=false [HOT, NO_PREFERENCE] - * "하나도 안 맵게" confident=false [NO_PREFERENCE] + * 팀 #138 이 서버에 부정어 필터를 넣어 고쳤다. 배포본으로 확인한 뒤 그 겹을 + * 걷어냈다. 같은 판단을 두 곳에서 하면, 어긋났을 때 어느 쪽이 옳은지 알기 + * 어려워진다. 판단은 서버 한 곳에서 한다. * - * 매운 것을 못 드시는 분이 "안 매운 거" 라고 말하면 매운맛이 들어간다. - * 그래서 우리 쪽 부정 표(voice.ts 의 아니라는말)로 한 번 거른다 — 그 표는 - * 이 문제를 이미 알고 만든 것이다. + * 그 대신 **서버가 되돌아가면 이 앱은 그대로 따라간다.** 시험으로는 못 잡는다 — + * 시험은 fetch 를 흉내 내므로 서버가 무엇을 답하든 초록이다. 배포 뒤에 실서버로 + * 한 번 물어보는 수밖에 없다(방법은 spicy.test.ts 의 curl 에 적어 뒀다). * * ── 실패하면 조용히 물러난다 ───────────────────────────────────────────────── * @@ -50,18 +50,19 @@ export type 맵기결과 = | { 못함: true }; /** - * 서버가 준 enum 목록을 화면 칩 이름으로. + * 서버가 준 enum 목록을 화면 칩 이름으로. 화면에 없는 이름은 뺀다. * - * 모르는 값이 있었는지도 같이 돌려준다. 그걸 조용히 버리면 **서버의 '모르겠다' - * 가 우리 쪽에서 '확정' 으로 바뀐다** — 서버가 [MEDIUM, EXTRA_HOT] 을 놓고 - * 확신 못 한다고 했는데, 우리가 EXTRA_HOT 을 버리면 보통맛 하나만 남아서 - * 마치 정해진 것처럼 보인다. 서버가 망설인 것을 우리가 없앨 수는 없다. + * 몇 개가 빠졌는지는 이제 안 센다. 예전에는 셌다 — 후보가 하나만 남으면 그것을 + * 확정으로 삼았기 때문에, 우리가 버려서 하나가 된 것인지 원래 하나였는지를 + * 갈라야 했다. 지금은 **확신 못 한 답은 개수와 상관없이 늘 되묻는다**(아래). + * 그래서 그 구분이 필요 없어졌다. */ -const 이름으로 = (값들: unknown): { 이름들: string[]; 모르는것있나: boolean } => { - if (!Array.isArray(값들)) return { 이름들: [], 모르는것있나: false }; - const 글자만 = 값들.filter((v): v is string => typeof v === "string"); - const 이름들 = 글자만.map((v) => 칩이름[v]).filter(Boolean); - return { 이름들, 모르는것있나: 이름들.length !== 글자만.length }; +const 이름으로 = (값들: unknown): string[] => { + if (!Array.isArray(값들)) return []; + return 값들 + .filter((v): v is string => typeof v === "string") + .map((v) => 칩이름[v]) + .filter(Boolean); }; /** @@ -73,7 +74,7 @@ const 이름으로 = (값들: unknown): { 이름들: string[]; 모르는것있 */ const 기다릴시간 = 8000; -export const 맵기물어보기 = async (들은말: string, 영어인가 = false): Promise<맵기결과> => { +export const 맵기물어보기 = async (들은말: string): Promise<맵기결과> => { const 글 = 들은말.replace(/\s+/g, " ").trim(); // 서버가 @Size(max = 100) 을 걸어 두었다. 넘겨 봐야 400 이라 여기서 접는다. if (글 === "" || 글.length > 100) return { 못함: true }; @@ -113,34 +114,31 @@ export const 맵기물어보기 = async (들은말: string, 영어인가 = false { confident?: boolean; matchedLevel?: string; candidates?: unknown } | null; if (!본문) return { 못함: true }; - /* - * 부정으로 거른다. 서버가 뭐라고 했든, 사용자가 "그건 아니다" 라고 말한 - * 값은 안 받는다. - * - * 확정이 걸리면 되물음으로 내린다 — 버리지 않는 이유는, 부정했다는 것은 - * 무엇을 원하는지가 아니라 무엇을 원하지 않는지만 말한 것이라 여전히 - * 물어봐야 하기 때문이다. - */ - const { 이름들: 원래후보, 모르는것있나 } = 이름으로(본문.candidates); - const 남은후보 = 원래후보.filter((이름) => !아니라고했나(글, 이름, 영어인가)); - const 부정걸림 = 남은후보.length !== 원래후보.length; + const 후보 = 이름으로(본문.candidates); if (본문.confident === true && typeof 본문.matchedLevel === "string") { const 이름 = 칩이름[본문.matchedLevel]; - if (이름 && !아니라고했나(글, 이름, 영어인가)) return { 고른값: 이름 }; - // 확정인데 우리 표가 아니라고 한다. 남은 것으로 되묻는다. - const 나머지 = 남은후보.filter((n) => n !== 이름); - return 나머지.length > 0 ? { 되물을것: 나머지 } : { 못함: true }; + // 화면에 없는 이름이면 사용자에게 내밀 수 없다. 손으로 고르게 둔다. + return 이름 ? { 고른값: 이름 } : { 못함: true }; } - if (남은후보.length === 0) return { 못함: true }; /* - * 부정이 걸렸으면 **하나만 남아도 묻는다.** + * 여기부터는 서버가 **확신하지 못한** 답이다. 하나만 남아도 우리가 고르지 + * 않는다. + * + * 예전에는 후보가 하나면 그것을 확정으로 삼았다. 그런데 서버는 확신하지 + * 못할 때 되물을 문장(clarificationQuestion)까지 같이 보낸다 — 그쪽도 묻고 + * 싶다는 뜻이다. 그걸 우리가 대신 골라 버리면 안 된다. + * + * #138 전에 실제로 그랬다. "하나도 안 맵게" 에 confident=false 이면서 + * candidates 가 ["NO_PREFERENCE"] 하나뿐이었고, 우리는 그것을 자동으로 + * 골랐다 — 맵기를 못 드셔서 그렇게 말한 분의 주문이 물어본 적도 없이 + * '상관없음' 으로 넘어갔다. (지금 서버는 같은 말에 ["MILD","MEDIUM"] 을 + * 준다. 고쳐졌지만, 규칙은 후보 개수가 아니라 confident 에 걸어 둔다.) * - * 사용자는 원하지 않는 것만 말했다("안 매운 거"). 남은 하나가 그 사람이 - * 원하는 것이라는 보장은 없다 — 순한맛일 수도 보통맛일 수도 있고, 서버가 - * 그 둘 중 하나만 후보로 올렸을 수도 있다. 우리가 고르면 그건 짐작이다. + * 부정어 방어를 걷어낸 지금은 이 겹이 더 중요하다 — 예전에는 우리 부정어 표에 + * 걸리면 되물음으로 내려가는 길이 하나 더 있었지만, 이제 없다. */ - if (남은후보.length === 1 && !부정걸림 && !모르는것있나) return { 고른값: 남은후보[0] }; - return { 되물을것: 남은후보 }; + if (후보.length === 0) return { 못함: true }; + return { 되물을것: 후보 }; }; diff --git a/frontend/src/api/vad.test.ts b/frontend/src/api/vad.test.ts new file mode 100644 index 0000000..d6cfa31 --- /dev/null +++ b/frontend/src/api/vad.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { 말끝지켜보기 } from "./vad"; + +/* + * 이 파일이 지키는 것. + * + * ① 말이 있었고 그 뒤 조용해져야 '끝' 이다. 조용하기만 한 것은 끝이 아니다. + * ② 아무 말도 없으면 그렇게 알린다 — 부르는 쪽이 빈 녹음을 서버로 보내지 + * 않게 하는 유일한 신호다. + * ③ **못 하는 자리에서는 조용히 물러난다.** 오디오를 못 다루는 브라우저에서 + * 던지면, 말로 답하는 길이 통째로 막힌다. 사람이 누르는 길은 그대로 있다. + * + * 소리 크기는 가짜 오디오로 만든다. 실제 마이크 없이 '조용함 → 말 → 조용함' + * 을 시간 순서대로 흉내 내야 타이밍을 볼 수 있다. + */ + +/** 지금 흉내 낼 소리 크기. 0 이면 무음, 1 에 가까울수록 크다. */ +let 크기 = 0; + +const 가짜스트림 = {} as MediaStream; + +const 붙이기 = () => { + const 분석기 = { + fftSize: 1024, + getByteTimeDomainData: (칸: Uint8Array) => { + // 128 이 무음이다. 크기만큼 위아래로 흔든다. + for (let i = 0; i < 칸.length; i++) 칸[i] = 128 + Math.round(크기 * 127) * (i % 2 === 0 ? 1 : -1); + }, + connect: vi.fn(), + }; + const 소스 = { connect: vi.fn(), disconnect: vi.fn() }; + const ctx = { + createAnalyser: () => 분석기, + createMediaStreamSource: () => 소스, + resume: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; + vi.stubGlobal("AudioContext", vi.fn(() => ctx)); + return { ctx, 소스 }; +}; + +/** 이만큼 시간이 흐른다. 재는 간격이 50ms 라 그 배수로 움직인다. */ +const 흐르기 = (ms: number) => vi.advanceTimersByTime(ms); + +beforeEach(() => { vi.useFakeTimers(); 크기 = 0; }); +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); + +describe("말이 끝나는 순간", () => { + it("말이 있었고 그 뒤 조용해지면 끝이라고 알린다", () => { + 붙이기(); + const 끝남 = vi.fn(); + 말끝지켜보기(가짜스트림, { 말이끝나면: 끝남 }); + + // 바탕 소음을 재는 동안(400ms)은 아무 판단도 안 한다. + 흐르기(500); + expect(끝남).not.toHaveBeenCalled(); + + 크기 = 0.5; // 말한다 + 흐르기(600); + expect(끝남).not.toHaveBeenCalled(); + + 크기 = 0; // 말을 멈춘다 + 흐르기(600); + // 아직 1200ms 가 안 됐다. 문장 사이 숨 고르기를 끝으로 보면 안 된다. + expect(끝남).not.toHaveBeenCalled(); + + 흐르기(800); + expect(끝남).toHaveBeenCalledTimes(1); + }); + + it("한 번 알린 뒤에는 더 안 부른다", () => { + 붙이기(); + const 끝남 = vi.fn(); + 말끝지켜보기(가짜스트림, { 말이끝나면: 끝남 }); + 흐르기(500); + 크기 = 0.5; 흐르기(400); + 크기 = 0; 흐르기(3000); + expect(끝남).toHaveBeenCalledTimes(1); + }); + + it("툭 하는 짧은 소리는 말로 안 본다", () => { + /* + * 50ms 짜리 소리 하나로 '말이 시작됐다' 로 치면, 그 뒤 조용해지는 순간 + * 녹음이 끝난다 — 사람은 아직 입도 안 뗐는데. + */ + 붙이기(); + const 끝남 = vi.fn(); + const 없음 = vi.fn(); + 말끝지켜보기(가짜스트림, { 말이끝나면: 끝남, 아무말도없으면: 없음 }, { 기다림: 3000 }); + 흐르기(500); + 크기 = 0.6; 흐르기(50); // 툭 + 크기 = 0; 흐르기(3000); + expect(끝남).not.toHaveBeenCalled(); + // 말이 시작된 적이 없으므로 '아무 말도 없음' 으로 접힌다. + expect(없음).toHaveBeenCalledTimes(1); + }); +}); + +describe("아무 말도 없을 때", () => { + it("기다림이 지나면 알린다", () => { + 붙이기(); + const 없음 = vi.fn(); + 말끝지켜보기(가짜스트림, { 말이끝나면: vi.fn(), 아무말도없으면: 없음 }, { 기다림: 2000 }); + 흐르기(1500); + expect(없음).not.toHaveBeenCalled(); + 흐르기(1000); + expect(없음).toHaveBeenCalledTimes(1); + }); + + it("말이 시작된 뒤에는 기다림으로 접지 않는다", () => { + // 길게 말하는 사람을 도중에 끊으면 안 된다. 그 그물은 listen.ts 의 최대 + // 녹음 시간이 따로 친다. + 붙이기(); + const 없음 = vi.fn(); + const 끝남 = vi.fn(); + 말끝지켜보기(가짜스트림, { 말이끝나면: 끝남, 아무말도없으면: 없음 }, { 기다림: 1000 }); + 흐르기(500); + 크기 = 0.5; 흐르기(3000); + expect(없음).not.toHaveBeenCalled(); + expect(끝남).not.toHaveBeenCalled(); + }); +}); + +describe("바탕 소음이 큰 곳", () => { + it("시끄러운 곳에서도 말이 솟을 때만 본다", () => { + /* + * 못 박은 값으로 재면 식당 앞에서는 바탕 소음이 늘 '말' 이 되어, 조용해지는 + * 순간이 오지 않는다. 처음 얼마를 재서 그 자리 바탕을 잡는 이유다. + */ + 붙이기(); + const 끝남 = vi.fn(); + 크기 = 0.2; // 시끄러운 곳 + 말끝지켜보기(가짜스트림, { 말이끝나면: 끝남 }); + 흐르기(500); // 이 동안의 0.2 가 바탕이 된다 + 흐르기(2000); + // 바탕 그대로면 말이 아니다. 여기서 끝났다고 하면 안 된다. + expect(끝남).not.toHaveBeenCalled(); + + 크기 = 0.8; 흐르기(400); // 바탕 위로 솟는다 = 말 + 크기 = 0.2; 흐르기(1500); // 바탕으로 돌아온다 = 조용해짐 + expect(끝남).toHaveBeenCalledTimes(1); + }); +}); + +describe("그만보기", () => { + it("그만두면 더 안 알리고 오디오도 놓는다", () => { + const { ctx, 소스 } = 붙이기(); + const 끝남 = vi.fn(); + const { 그만보기 } = 말끝지켜보기(가짜스트림, { 말이끝나면: 끝남 }); + 흐르기(500); + 크기 = 0.5; 흐르기(400); + 그만보기(); + expect(소스.disconnect).toHaveBeenCalled(); + expect(ctx.close).toHaveBeenCalled(); + 크기 = 0; 흐르기(3000); + expect(끝남).not.toHaveBeenCalled(); + }); +}); + +describe("못 하는 자리에서는 조용히 물러난다", () => { + it("AudioContext 가 없으면 던지지 않는다", () => { + // 여기서 던지면 들어보기() 가 통째로 깨져 말로 답하는 길이 막힌다. + vi.stubGlobal("AudioContext", undefined); + vi.stubGlobal("webkitAudioContext", undefined); + const 끝남 = vi.fn(); + const r = 말끝지켜보기(가짜스트림, { 말이끝나면: 끝남 }); + expect(() => r.그만보기()).not.toThrow(); + 흐르기(5000); + expect(끝남).not.toHaveBeenCalled(); + }); + + it("오디오를 만들다 실패해도 던지지 않는다", () => { + vi.stubGlobal("AudioContext", vi.fn(() => { throw new Error("no audio"); })); + const 끝남 = vi.fn(); + expect(() => 말끝지켜보기(가짜스트림, { 말이끝나면: 끝남 })).not.toThrow(); + 흐르기(5000); + expect(끝남).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/api/vad.ts b/frontend/src/api/vad.ts new file mode 100644 index 0000000..a8a400e --- /dev/null +++ b/frontend/src/api/vad.ts @@ -0,0 +1,172 @@ +/** + * 말이 끝났는지를 **기기 안에서** 지켜본다. + * + * ── 왜 필요한가 ───────────────────────────────────────────────────────────── + * + * 녹음은 말이 끝난 것을 스스로 못 알아챈다(listen.ts). 그래서 지금은 사람이 + * "그만 듣기" 를 눌러야 한 답이 끝난다 — 한 칸에 두 번, 다섯 칸이면 열 번이다. + * + * 화면을 못 보는 분에게는 그 열 번이 전부 장벽이다. 어디를 눌러야 하는지 + * 찾는 일이 답하는 일보다 오래 걸린다. 말이 끝난 것을 앱이 알아채면 그 열 + * 번이 사라진다. + * + * ── 소리는 밖으로 안 나간다 ───────────────────────────────────────────────── + * + * 여기서 보는 것은 **소리의 크기뿐**이다. AnalyserNode 가 브라우저 안에서 + * 파형을 재고, 우리는 그 숫자만 읽는다. 녹음한 소리가 서버로 가는 것은 지금과 + * 똑같이 listen.ts 가 한 번 보낼 때뿐이고, 이 파일은 아무것도 보내지 않는다. + * + * AudioWorklet 이 아니라 AnalyserNode 를 쓴다. 둘 다 기기 안에서 돌지만 + * AnalyserNode 는 파일을 따로 두지 않아도 되고, 50ms 마다 한 번 읽는 정도는 + * 본 스레드에서도 가볍다. + * + * ── 기기마다 조용한 정도가 다르다 ─────────────────────────────────────────── + * + * "이 값을 넘으면 말소리" 로 못 박으면 안 된다. 조용한 방과 식당 앞은 바탕 + * 소음이 열 배 넘게 차이 난다 — 못 박은 값은 한쪽에서는 아무 말도 못 듣고 + * 다른 쪽에서는 계속 말하는 중이라고 본다. + * + * 그래서 처음 얼마 동안을 재서 그 자리의 바탕 소음을 먼저 잡고, 거기서 얼마나 + * 솟는지로 본다. + */ + +/** 얼마나 자주 소리 크기를 읽나. 50ms 면 사람이 말을 멈춘 것을 놓치지 않는다. */ +const 재는간격 = 50; + +/** 이만큼은 바탕 소음을 재는 데 쓴다. 이 동안의 소리는 말로 세지 않는다. */ +const 바탕재는시간 = 400; + +/** 바탕 소음보다 이 배수 넘게 솟으면 말소리로 본다. */ +const 솟는배수 = 2.2; + +/** + * 아주 조용한 방에서는 바탕이 0에 가까워 배수만으로는 숨소리도 말이 된다. + * 바닥값을 둔다 — 이보다 작으면 배수와 상관없이 말로 안 본다. + */ +const 최소크기 = 0.012; + +/** 말이 이만큼 끊기면 끝난 것으로 본다. 문장 사이 숨 고르기보다는 길게. */ +const 끝으로볼무음 = 1200; + +/** 말소리가 이만큼 이어져야 '말이 시작됐다' 로 친다. 툭 하는 소리를 거른다. */ +const 말로볼길이 = 150; + +export interface 말지켜보기결과 { + /** 지켜보기를 그만둔다. 오디오 자원도 같이 놓는다. */ + 그만보기: () => void; +} + +/** + * 스트림에서 말이 끝나는 순간을 알려 준다. + * + * `말이끝나면` — 말이 있었고 그 뒤 조용해졌다. 녹음을 끝내면 되는 자리다. + * `아무말도없으면` — 기다리는 동안 말이 한 번도 없었다. 부르는 쪽이 "안 + * 들렸어요" 로 접을지, 그냥 기다릴지 정한다. + * + * 둘 중 하나가 한 번 불리면 더는 안 부른다. 지켜보기도 스스로 접는다. + */ +export const 말끝지켜보기 = ( + 스트림: MediaStream, + { 말이끝나면, 아무말도없으면 }: { 말이끝나면: () => void; 아무말도없으면?: () => void }, + { 기다림 = 8000 }: { 기다림?: number } = {}, +): 말지켜보기결과 => { + const AudioCtx = globalThis.AudioContext + ?? (globalThis as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + // 오디오를 못 다루는 브라우저다. 지켜보지 않는다 — 부르는 쪽은 예전처럼 + // 사람이 '그만 듣기' 를 누르는 길과 최대 녹음 시간으로 그대로 돌아간다. + if (!AudioCtx) return { 그만보기: () => {} }; + + let ctx: AudioContext; + let 분석기: AnalyserNode; + let 소스: MediaStreamAudioSourceNode; + try { + ctx = new AudioCtx(); + 분석기 = ctx.createAnalyser(); + 분석기.fftSize = 1024; + 소스 = ctx.createMediaStreamSource(스트림); + 소스.connect(분석기); + } catch { + return { 그만보기: () => {} }; + } + /* + * 사람이 누른 자리가 아니면 오디오가 멈춘 채로 만들어지는 브라우저가 있다. + * 깨워 둔다 — 실패해도 그냥 둔다. 그러면 크기가 늘 0 이라 '아무 말도 없음' + * 으로 접히고, 부르는 쪽이 예전 길로 돌아간다. + */ + void ctx.resume?.().catch(() => {}); + + const 칸 = new Uint8Array(분석기.fftSize); + let 끝났나 = false; + let 시계: ReturnType | undefined; + const 바탕표본: number[] = []; + let 바탕 = 0; + let 말시작함 = false; + let 말이어진시간 = 0; + let 조용한시간 = 0; + let 지난시간 = 0; + + const 접기 = () => { + if (끝났나) return; + 끝났나 = true; + clearInterval(시계); + try { 소스.disconnect(); } catch { /* 이미 끊겼다 */ } + // 스트림 자체는 안 끈다 — 그건 녹음하는 쪽(listen.ts)의 것이다. + void ctx.close?.().catch(() => {}); + }; + + /** 지금 이 순간 소리가 얼마나 큰가(RMS). 0 에 가까우면 조용하다. */ + const 지금크기 = (): number => { + 분석기.getByteTimeDomainData(칸); + let 합 = 0; + for (let i = 0; i < 칸.length; i++) { + // 128 이 무음이다. 거기서 얼마나 벗어났는지를 본다. + const v = (칸[i] - 128) / 128; + 합 += v * v; + } + return Math.sqrt(합 / 칸.length); + }; + + 시계 = setInterval(() => { + if (끝났나) return; + 지난시간 += 재는간격; + const 크기 = 지금크기(); + + // ① 처음 얼마 동안은 이 자리의 바탕 소음을 잰다. + if (지난시간 <= 바탕재는시간) { + /* + * 평균이 아니라 **아래쪽 값**을 쓴다. + * + * 평균을 쓰면 이 동안 사람이 말을 시작한 경우 그 말소리가 바탕을 밀어 + * 올린다. 그러면 문턱이 실제 목소리보다 높아져 그 뒤로 아무것도 말로 + * 안 잡히고, 기다림이 다 지나 '아무 말도 없음' 으로 접힌다 — 말을 했는데 + * 녹음이 버려진다. + * + * 이어 듣기에서는 이게 흔한 자리다. 안내가 끝나자마자 바로 답하는 것이 + * 정상이고, 잇달아 두 번 실패하면 이어 듣기가 꺼진다. 손을 안 쓰는 분은 + * 거기서 흐름이 멎는다. + * + * 아래쪽 4분의 1 지점을 쓰면 표본에 말소리가 섞여도 바탕이 안 튄다. + */ + 바탕표본.push(크기); + const 정렬 = [...바탕표본].sort((a, b) => a - b); + 바탕 = 정렬[Math.floor((정렬.length - 1) * 0.25)]; + return; + } + + const 말소리인가 = 크기 > Math.max(바탕 * 솟는배수, 최소크기); + + // ② 아직 말이 시작되지 않았다. + if (!말시작함) { + 말이어진시간 = 말소리인가 ? 말이어진시간 + 재는간격 : 0; + if (말이어진시간 >= 말로볼길이) { 말시작함 = true; 조용한시간 = 0; return; } + if (지난시간 >= 기다림) { 접기(); 아무말도없으면?.(); } + return; + } + + // ③ 말이 시작됐다. 이제는 조용해지기를 기다린다. + 조용한시간 = 말소리인가 ? 0 : 조용한시간 + 재는간격; + if (조용한시간 >= 끝으로볼무음) { 접기(); 말이끝나면(); } + }, 재는간격); + + return { 그만보기: 접기 }; +}; diff --git a/frontend/src/api/voice.test.ts b/frontend/src/api/voice.test.ts index 6593b52..e2b735e 100644 --- a/frontend/src/api/voice.test.ts +++ b/frontend/src/api/voice.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { 말에서고르기, 말로채울수있나, 예아니오 } from "./voice"; +import { 말에서고르기, 말로채울수있나, 예아니오, 뒤로가자고했나, 다음가자고했나 } from "./voice"; /* * 이 파일이 지키는 것 하나 — **말하지 않은 조건이 주문표에 섞이지 않는다.** @@ -229,3 +229,84 @@ describe("예/아니오 — 애매한 것을 예나 아니오로 읽지 않는 expect(예아니오("yes", true)).toBe(true); }); }); + +describe("앞 질문으로 돌아가 달라는 말", () => { + /* + * 화면을 못 보는 분에게는 이 말이 앞 칸을 고칠 유일한 길이다. 단추는 눈으로 + * 보고 누르는 분을 위한 같은 문이고, 이 표가 손을 안 쓰는 쪽을 연다. + */ + it("되돌아가자는 말을 알아듣는다", () => { + for (const 말 of ["뒤로", "뒤로 가 주세요", "이전", "이전 질문이요", "되돌려 주세요", "앞 질문"]) { + expect(뒤로가자고했나(말)).toBe(true); + } + }); + + it("보기 이름과 평범한 답은 명령으로 읽지 않는다", () => { + /* + * 여기 걸리면 그 말은 답이 아니라 명령이 된다. 실제로 나올 법한 답이 + * 걸리면, 고르려던 값 대신 앞 칸으로 튕겨 나간다. + */ + for (const 말 of ["포장하기", "매운맛", "순살", "두 개", "네", "아니요", "먹고 갈게요", "종이컵"]) { + expect(뒤로가자고했나(말)).toBe(false); + } + }); + + it("빈 말은 아니다", () => { + expect(뒤로가자고했나("")).toBe(false); + expect(뒤로가자고했나(" ")).toBe(false); + }); + + it("영어는 낱말로 있을 때만 본다", () => { + // 'boneless' 는 순살의 영어 답이다. 그 안의 'bone' 처럼 글자만 겹치는 것을 + // 세면, 순살을 말한 사람이 앞 칸으로 튕긴다(#39 리뷰와 같은 이유). + expect(뒤로가자고했나("back", true)).toBe(true); + expect(뒤로가자고했나("go back please", true)).toBe(true); + expect(뒤로가자고했나("previous", true)).toBe(true); + expect(뒤로가자고했나("backbone", true)).toBe(false); + expect(뒤로가자고했나("boneless", true)).toBe(false); + expect(뒤로가자고했나("two spicy boneless to go", true)).toBe(false); + }); + + it("우리말 표는 영어 화면에서 안 걸린다", () => { + // 영어 화면에서는 인식기도 en-US 라 우리말이 올 일이 없다. 표를 섞지 않는다. + expect(뒤로가자고했나("뒤로", true)).toBe(false); + }); +}); + +describe("다음으로 가 달라는 말", () => { + /* + * 여러 개 고르는 칸(카페의 시럽)에서는 이 말이 없으면 나갈 길이 없다. + * 값을 넣어도 그 자리에 머무는 칸이라, 손을 안 쓰는 분은 같은 질문만 + * 되풀이해 듣다가 이어 듣기가 꺼진다(#145 리뷰). + */ + it("다음으로 가자는 말을 알아듣는다", () => { + for (const 말 of ["다음", "다음이요", "됐어요", "됐습니다", "충분해요", "넘어가 주세요"]) { + expect(다음가자고했나(말)).toBe(true); + } + }); + + it("보기 이름과 평범한 답은 명령으로 읽지 않는다", () => { + for (const 말 of ["바닐라", "헤이즐넛", "카라멜", "시럽 없음", "매운맛", "포장하기", "네", "두 개"]) { + expect(다음가자고했나(말)).toBe(false); + } + }); + + it("영어는 낱말로 있을 때만 본다", () => { + expect(다음가자고했나("next", true)).toBe(true); + expect(다음가자고했나("that is enough", true)).toBe(true); + // 'nextdoor' 안의 next 가 걸리면 안 된다. + expect(다음가자고했나("nextdoor", true)).toBe(false); + expect(다음가자고했나("vanilla", true)).toBe(false); + }); + + it("뒤로와 겹치지 않는다", () => { + // 둘 다 참이면 앞의 검사(뒤로)가 이겨서 다음으로 못 간다. + for (const 말 of ["다음", "됐어요", "넘어가"]) expect(뒤로가자고했나(말)).toBe(false); + for (const 말 of ["뒤로", "이전", "되돌려"]) expect(다음가자고했나(말)).toBe(false); + }); + + it("빈 말은 아니다", () => { + expect(다음가자고했나("")).toBe(false); + expect(다음가자고했나(" ")).toBe(false); + }); +}); diff --git a/frontend/src/api/voice.ts b/frontend/src/api/voice.ts index 4564263..3ba7ef1 100644 --- a/frontend/src/api/voice.ts +++ b/frontend/src/api/voice.ts @@ -113,26 +113,12 @@ const 낱말로있나 = (글: string, 말: string): boolean => { return new RegExp(`(^|[^a-z0-9])${그대로}([^a-z0-9]|$)`).test(글); }; -/** - * 이 값이 **아니라고** 말했나. - * - * 위 두 표(아니라는말·영어로아니라는말)를 밖에서도 쓸 수 있게 연다. 서버가 - * 골라 준 값을 받을 때 필요하다 — 서버는 임베딩 유사도로 고르는데, 그 방식은 - * 부정을 못 읽는다. "안 매운 거" 안에 "매운" 이 들어 있어서 매운맛과 가깝다고 - * 본다(실측: confident=true, HOT). 우리 쪽 표는 그걸 이미 알고 있으므로, - * 서버 답이 여기 걸리면 안 받는다. - * - * 넓히는 것이 아니라 **반대로 읽는 것을 막는 것**이라, 짐작하지 않는다는 - * 규칙과 어긋나지 않는다. +/* + * 위 두 표를 밖으로 열어 두던 아니라고했나() 가 여기 있었다. 서버가 부정을 + * 못 읽던 때 spicy.ts 가 서버 답을 되거르는 데 쓰던 것인데, 팀 #138 이 서버에 + * 부정어 필터를 넣어 고친 뒤로 부르는 곳이 없어져 지웠다. 표 자체는 아래 + * 말했나() 가 계속 쓴다 — 화면 보기와 글자를 대조하는 우리 쪽 길이다. */ -export const 아니라고했나 = (들은말: string, 값: string, 영어인가 = false): boolean => { - const 글 = 들은말.replace(/\s+/g, " ").trim(); - if (영어인가) { - const 소문자 = 글.toLowerCase(); - return (영어로아니라는말[값] ?? []).some((말) => 낱말로있나(소문자, 말)); - } - return (아니라는말[값] ?? []).some((말) => 글.includes(말)); -}; /** 이 값을 말한 것으로 볼 수 있나. 값 자체나, 달리 부르는 말이 글에 있으면 그렇다. */ const 말했나 = (글: string, 값: string, 영어인가: boolean): boolean => { @@ -198,6 +184,54 @@ const 축얘기를했나 = (글: string, 축: string, 영어인가: boolean): bo : 말들.ko.some((말) => 글.includes(말)); }; +/** + * 앞 질문으로 돌아가 달라는 말인가. + * + * 한 칸씩 묻는 흐름에는 앞으로 가는 길만 있었다 — 말하기·건너뛰기뿐이라, 두 + * 번째 칸에 잘못 답하고 나면 되돌릴 방법이 없었다. 눈으로 보는 사람은 화면 + * 뒤로가기로 흐름을 통째로 나갔다가 처음부터 다시 할 수 있지만, 그건 되돌리기가 + * 아니라 포기다. + * + * 손으로 누르는 단추도 같이 두지만, 이 표가 있어야 **손을 안 쓰는 사람**이 + * 되돌릴 수 있다. 화면을 못 보는 분에게는 이쪽이 유일한 길이다. + * + * 표를 짧게 둔다. 여기 걸린 말은 답이 아니라 명령으로 읽히므로, 보기 이름과 + * 겹칠 만한 말은 넣지 않는다 — 아래 말들은 어느 축의 목록에도 없다. + */ +const 뒤로가자는말 = ["뒤로", "이전", "되돌려", "되돌리", "앞 질문", "앞질문"]; +const 영어로뒤로가자는말 = ["back", "previous"]; + +export const 뒤로가자고했나 = (들은말: string, 영어인가 = false): boolean => { + const 글 = 들은말.replace(/\s+/g, " ").trim(); + if (글 === "") return false; + return 영어인가 + // 낱말째로 본다 — "backbone" 안의 back 이 걸리면 안 된다(낱말로있나 주석). + ? 영어로뒤로가자는말.some((말) => 낱말로있나(글.toLowerCase(), 말)) + : 뒤로가자는말.some((말) => 글.includes(말)); +}; + +/** + * 이 칸은 됐으니 다음으로 가 달라는 말인가. + * + * **여러 개 고르는 칸에서는 이 말이 없으면 나갈 길이 없다.** 그런 칸(카페의 + * 시럽)은 값을 넣어도 그 자리에 머문다 — 더 고를 수 있어야 하기 때문이다. + * 화면에는 '건너뛰기' 단추가 있지만, 손을 안 쓰는 분에게는 그 단추가 없는 + * 것과 같다. 같은 질문만 되풀이되다가 이어 듣기가 꺼진다. + * + * 뒤로가자는말 과 같은 규칙이다 — 짧게 두고, 보기 이름과 겹칠 말은 안 넣는다. + * '됐어요' 는 답이 아니라 명령으로만 쓰인다. + */ +const 다음가자는말 = ["다음", "됐어요", "됐습니다", "그만할래", "충분해", "넘어가"]; +const 영어로다음가자는말 = ["next", "done", "skip", "enough"]; + +export const 다음가자고했나 = (들은말: string, 영어인가 = false): boolean => { + const 글 = 들은말.replace(/\s+/g, " ").trim(); + if (글 === "") return false; + return 영어인가 + ? 영어로다음가자는말.some((말) => 낱말로있나(글.toLowerCase(), 말)) + : 다음가자는말.some((말) => 글.includes(말)); +}; + /** * 예/아니오만 가려낸다. 한 칸씩 물을 때 쓴다. * diff --git a/frontend/src/api/wake.test.ts b/frontend/src/api/wake.test.ts new file mode 100644 index 0000000..b9cc00c --- /dev/null +++ b/frontend/src/api/wake.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { 불렀나, 물어보지않고들을수있나 } from "./wake"; + +/* + * 이 파일이 지키는 것. + * + * ① 우리를 부른 말만 문을 연다. 옆 사람 대화가 앱을 시작시키면 안 된다. + * ② 인식기가 또박또박 안 돌려줘도 알아듣는다 — 띄어쓰기·받아쓰기 어긋남. + * ③ **묻지 않고 마이크를 열 수 있을 때만** 상시 대기를 켠다. 모르면 안 켠다. + */ + +afterEach(() => { vi.unstubAllGlobals(); }); + +describe("우리를 부른 말인가", () => { + it("또박또박 부르면 연다", () => { + expect(불렀나("키오브릿지")).toBe(true); + expect(불렀나("키오브릿지!")).toBe(true); + }); + + it("띄어쓰기가 달라도 연다", () => { + // 인식기는 "키오 브릿지" 로 적어 오기도 한다. 띄어쓰기로 문이 안 열리면 + // 사용자는 자기가 잘못 말한 줄 안다. + expect(불렀나("키오 브릿지")).toBe(true); + expect(불렀나("키 오 브 릿 지")).toBe(true); + }); + + it("앞뒤에 말이 붙어도 연다", () => { + // "저기, 키오브릿지야" 처럼 실제로는 문장으로 부른다. + expect(불렀나("저기 키오브릿지야")).toBe(true); + expect(불렀나("키오브릿지 시작해줘")).toBe(true); + }); + + it("받아쓰기가 조금 어긋나도 연다", () => { + // 실제로 이렇게 적어 온 것들이다. 완벽한 인식만 받으면 대부분 안 열린다. + expect(불렀나("키오브리지")).toBe(true); + expect(불렀나("기오브릿지")).toBe(true); + }); + + it("영어로 적어 와도 연다", () => { + expect(불렀나("KioBridge")).toBe(true); + expect(불렀나("kiobridge please")).toBe(true); + }); + + it("우리를 부른 것이 아니면 안 연다", () => { + /* + * 이 문이 헐거우면 옆 대화 한 마디에 앱이 제멋대로 시작한다. 화면을 못 + * 보는 분에게는 무슨 일이 일어났는지 알 방법이 없다. + */ + for (const 말 of [ + "", " ", "시작", "시작해줘", "여보세요", "브릿지", "키오스크", + "오늘 날씨 어때", "치킨 먹고 싶다", "bridge", "start", + ]) { + expect(불렀나(말)).toBe(false); + } + }); +}); + +describe("묻지 않고 마이크를 열 수 있나", () => { + /* + * 여기가 상시 대기의 전제다. 아직 안 물어본 자리에서 켜면, 사용자가 아무것도 + * 안 했는데 권한 창이 뜬다. 모르면 안 켠다. + */ + const 권한 = (state: string) => + vi.stubGlobal("navigator", { permissions: { query: async () => ({ state }) } }); + + it("이미 허용했으면 켠다", async () => { + 권한("granted"); + expect(await 물어보지않고들을수있나()).toBe(true); + }); + + it("아직 안 물어봤으면 안 켠다", async () => { + 권한("prompt"); + expect(await 물어보지않고들을수있나()).toBe(false); + }); + + it("막았으면 안 켠다", async () => { + 권한("denied"); + expect(await 물어보지않고들을수있나()).toBe(false); + }); + + it("Permissions API 가 없으면 안 켠다 — 모르면 안 켠다", async () => { + vi.stubGlobal("navigator", {}); + expect(await 물어보지않고들을수있나()).toBe(false); + }); + + it("물어보다 던져도 안 켜고, 던지지도 않는다", async () => { + // 사파리는 name:"microphone" 을 모른다며 던진다. 거기서 앱이 깨지면 안 된다. + vi.stubGlobal("navigator", { permissions: { query: async () => { throw new TypeError("bad name"); } } }); + await expect(물어보지않고들을수있나()).resolves.toBe(false); + }); +}); diff --git a/frontend/src/api/wake.ts b/frontend/src/api/wake.ts new file mode 100644 index 0000000..1fecc2a --- /dev/null +++ b/frontend/src/api/wake.ts @@ -0,0 +1,270 @@ +import { 들어보기, 들을수있나 } from "@/api/listen"; +import { 예아니오 } from "@/api/voice"; +import { 다읽을때까지 } from "@/api/speech"; + +/** + * 부르는 말을 기다린다. 손을 한 번도 안 대고 시작하기 위한 문. + * + * ── 왜 필요한가 ───────────────────────────────────────────────────────────── + * + * 이어 듣기(api/vad.ts)로 답하는 동안은 손을 안 대게 됐다. 그런데 **맨 처음 + * 한 번**은 여전히 '말하기' 를 눌러야 시작한다. 화면을 못 보는 분에게는 그 + * 한 번이 남은 장벽이다 — 어디를 눌러야 하는지 찾는 일이 문제이지, 누르는 + * 일이 문제가 아니다. + * + * ── 평소에는 아무것도 안 보낸다 ───────────────────────────────────────────── + * + * 상시 대기는 **기기 안에서만** 돈다. vad 가 소리의 크기만 재고 있다가, 사람이 + * 실제로 말한 뒤 조용해진 순간에만 그 한 토막을 서버로 보내 무슨 말이었는지 + * 묻는다(들어보기 의 스스로끝내기). 조용한 동안에는 한 바이트도 안 나간다. + * + * 그러니 "웨이크워드 인식을 기기 안에서 한다" 는 말은 아니다. **기다리는 일**이 + * 기기 안에서 돌고, 말이 났을 때만 그 말이 무엇인지를 서버에 묻는다. 이 앱은 + * 못 하는 것을 한다고 말하지 않는다 — 화면 안내도 그렇게 적는다. + * + * ── 끝없이 돌지 않는다 ────────────────────────────────────────────────────── + * + * 기다리기는 공짜가 아니다. 마이크가 켜져 있고, 누가 말할 때마다 인식 호출이 + * 하나씩 나간다. 옆에서 대화가 이어지는 자리라면 그게 계속 쌓인다. + * + * 그래서 두 가지 그물을 친다 — 물어본 횟수와 전체 시간. 어느 쪽이든 닿으면 + * 스스로 접고 그렇게 알린다. 조용히 멈추면 사용자는 아직 기다리는 줄 안다. + */ + +/** + * 부르는 말. + * + * 인식기가 늘 또박또박 돌려주지는 않는다 — "키오브릿지" 를 "키오 브릿지", + * "키오브리지", "기오브릿지" 로 적어 오는 일이 있다. 띄어쓰기를 지우고 견주며, + * 실제로 들어 본 어긋남을 여기에 적어 둔다. + * + * 짧고 흔한 말은 안 쓴다. "시작" 같은 말은 옆 사람 대화에도 나온다. + */ +const 부르는말 = [ + "키오브릿지", "키오브리지", "키오브릳지", "기오브릿지", "키오부릿지", + "kiobridge", "keyobridge", +]; + +/** 들은 글이 부르는 말인가. 띄어쓰기와 대소문자는 무시한다. */ +export const 불렀나 = (들은말: string): boolean => { + const 글 = 들은말.toLowerCase().replace(/[\s.,!?~]/g, ""); + if (글 === "") return false; + return 부르는말.some((말) => 글.includes(말)); +}; + +/** + * 이 브라우저에서 **묻지 않고** 마이크를 열 수 있는가. + * + * 여기가 이 기능의 전제다. 브라우저는 처음 한 번은 사람에게 물어야 마이크를 + * 준다 — 그 물음에 답하는 것은 우리 화면이 아니라 브라우저 자신의 창이다. + * 이미 허용해 둔 자리에서만 상시 대기를 켠다. + * + * 아직 안 물어본 자리에서 켜면 두 가지가 나쁘다. 사용자가 아무것도 안 했는데 + * 권한 창이 뜨고, 브라우저에 따라 그 요청이 조용히 막힌다. + * + * Permissions API 가 없는 브라우저에서는 false 를 준다 — 모르면 안 켠다. + */ +export const 물어보지않고들을수있나 = async (): Promise => { + try { + const p = await navigator.permissions?.query({ name: "microphone" as PermissionName }); + return p?.state === "granted"; + } catch { + return false; + } +}; + +/** + * 마이크가 지금 어떤 상태인가. 화면이 무슨 말을 할지 이걸로 정한다. + * + * 됨 이미 허용됐다. 부르는 말을 기다릴 수 있다. + * 물어봐야함 아직 안 물어봤다. **사람이 누른 자리에서** 한 번 물어야 한다. + * 막힘 막아 두었다. 우리가 다시 물을 수 없다 — 브라우저 설정에서만 푼다. + * 모름 Permissions API 가 없다(사파리 등). 물어봐야함 과 같이 다룬다. + * + * 모름 을 막힘 으로 치지 않는다. 모른다고 길을 닫으면, 될 수도 있는 기기에서 + * 아무 말 없이 아무것도 안 하게 된다. + */ +export type 마이크상태 = "됨" | "물어봐야함" | "막힘" | "모름"; + +export const 마이크상태보기 = async (): Promise<마이크상태> => { + if (!들을수있나()) return "막힘"; + try { + const p = await navigator.permissions?.query({ name: "microphone" as PermissionName }); + if (!p) return "모름"; + return p.state === "granted" ? "됨" : p.state === "denied" ? "막힘" : "물어봐야함"; + } catch { + return "모름"; + } +}; + +/** + * 마이크를 한 번 열어 브라우저가 묻게 한다. 허락되면 true. + * + * **사람이 누른 자리에서만 부른다.** 브라우저는 사용자가 누르지 않은 자리에서 + * 온 요청을 조용히 막는다 — 그러면 물어보지도 못하고 아무 일도 안 일어난다. + * + * 연 김에 바로 끈다. 여기서 하려는 일은 녹음이 아니라 허락을 받는 것뿐이다. + * 안 끄면 마이크 표시등이 켜진 채로 남아, 듣고 있지도 않은데 듣는 것처럼 보인다. + */ +export const 마이크허락받기 = async (): Promise => { + if (!들을수있나()) return false; + try { + const 스트림 = await navigator.mediaDevices.getUserMedia({ audio: true }); + 스트림.getTracks().forEach((t) => t.stop()); + return true; + } catch { + return false; + } +}; + +/** 이만큼 물어보고도 못 들으면 접는다. 인식 호출이 끝없이 쌓이지 않게. */ +const 최대물어보기 = 12; + +/** 이 시간이 지나면 접는다. 말이 아예 없어도 마이크를 계속 열어 두지 않는다. */ +const 최대기다림 = 3 * 60 * 1000; + +export interface 기다리기 { + /** 기다리기를 그만둔다. 화면을 떠나거나 사용자가 손으로 시작했을 때. */ + 그만기다리기: () => void; +} + +/** + * 부르는 말이 들릴 때까지 기다린다. + * + * `들리면` — 부르는 말이 왔다. 한 번만 부르고 스스로 접는다. + * `그만뒀으면` — 그물에 걸려 접었다. 화면이 "이제 안 듣고 있어요" 로 바꿔야 + * 한다. 조용히 멈추면 사용자는 아직 기다리는 줄 알고 계속 말한다. + */ +export const 부를때까지기다리기 = ( + 언어: string, + { 들리면, 그만뒀으면 }: { 들리면: () => void; 그만뒀으면?: () => void }, +): 기다리기 => { + let 접혔나 = false; + let 물어본횟수 = 0; + let 듣던것: { 그만두기: (보내기?: boolean) => void } | null = null; + const 시작한때 = Date.now(); + + const 접기 = (알릴까: boolean) => { + if (접혔나) return; + 접혔나 = true; + 듣던것?.그만두기(false); + 듣던것 = null; + if (알릴까) 그만뒀으면?.(); + }; + + const 한바퀴 = () => { + if (접혔나) return; + if (Date.now() - 시작한때 >= 최대기다림 || 물어본횟수 >= 최대물어보기) { 접기(true); return; } + + 듣던것 = 들어보기(언어, (r) => { + 듣던것 = null; + if (접혔나) return; + + /* + * 말이 없었다(소리없음). 물어본 것으로 세지 않는다 — 서버에 아무것도 안 + * 보냈기 때문이다. 시간 그물만 남는다. + */ + if (!("들은말" in r)) { + // 마이크가 막혔거나 이 기기에서 안 된다. 다시 열어도 같으므로 접는다. + if (r.못들은이유 === "권한없음" || r.못들은이유 === "안됨") { 접기(true); return; } + 한바퀴(); + return; + } + + 물어본횟수 += 1; + if (불렀나(r.들은말)) { 접기(false); 들리면(); return; } + // 우리를 부른 말이 아니다. 무슨 말이었는지는 어디에도 안 남긴다. + 한바퀴(); + }, { 스스로끝내기: true }); + }; + + 한바퀴(); + return { 그만기다리기: () => 접기(false) }; +}; + +/** 예/아니오를 못 알아들으면 이만큼까지 다시 묻는다. 더 하면 붙잡는 것이 된다. */ +const 다시묻기한도 = 3; + +/** + * 화면에 떠 있는 물음에 예/아니오를 말로 받는다. + * + * ── 왜 필요한가 ───────────────────────────────────────────────────────────── + * + * 동의는 건너뛸 수 없는 관문이다. 그런데 그 관문이 **체크박스**라, 화면을 못 + * 보는 분은 손을 안 대고는 지날 수 없었다. 부르는 말을 알아듣게 만들어 놓고도 + * 그 앞에서 막혀 있던 셈이다. + * + * 물음은 화면이 글로 띄운다. 이 함수는 그 글이 다 읽히기를 기다렸다가 답을 + * 듣는다 — 물음을 소리로 따로 만들지 않는 이유는, 화면에 없는 말을 귀로만 + * 들려주면 눈으로 보는 사람과 다른 것을 듣게 되기 때문이다. + * + * ── 답하기 전에 목소리가 한 번 나간다 ─────────────────────────────────────── + * + * 이 앱은 녹음을 서버로 보내 인식한다. 그래서 동의를 말로 받으려면, 동의하기 + * 전에 그 한마디가 서버를 다녀와야 한다 — 순서가 뒤집힌다. + * + * 숨기지 않는다. 화면의 물음 문장이 그 사실을 같이 말한다. 그렇게 적어 두고 + * 사용자가 말할지 말지를 고르게 하는 것이, 조용히 받아 두는 것보다 낫다. + * + * ── 아니라고 하면 다시 묻지 않는다 ────────────────────────────────────────── + * + * '아니오' 는 답이 없는 것이 아니라 답이다. 거기서 또 물으면 붙잡는 것이 된다. + * 못 알아들었을 때만 다시 묻고, 그것도 세 번까지다. + */ +export const 말로예아니오받기 = ( + 언어: string, + { 네라고하면, 아니라고하면, 그만뒀으면 }: { + 네라고하면: () => void; + 아니라고하면?: () => void; + 그만뒀으면?: () => void; + }, +): 기다리기 => { + let 접혔나 = false; + let 못알아들음 = 0; + let 듣던것: { 그만두기: (보내기?: boolean) => void } | null = null; + + const 접기 = (알릴까: boolean) => { + if (접혔나) return; + 접혔나 = true; + 듣던것?.그만두기(false); + 듣던것 = null; + if (알릴까) 그만뒀으면?.(); + }; + + const 한바퀴 = () => { + if (접혔나) return; + /* + * 물음이 다 읽히기 전에 마이크를 열면 두 가지가 어긋난다 — 듣기가 읽던 + * 것을 끊어서 무엇을 묻는지 못 듣게 되고, 안 끊더라도 스피커 소리가 + * 녹음에 실린다. 시작기다림 을 주는 이유는 speech.ts 주석에 있다. + */ + void 다읽을때까지(8000, { 시작기다림: 1500 }).then(() => { + if (접혔나) return; + 듣던것 = 들어보기(언어, (r) => { + 듣던것 = null; + if (접혔나) return; + + if (!("들은말" in r)) { + // 마이크가 막혔거나 이 기기에서 안 된다. 다시 열어도 같다. + if (r.못들은이유 === "권한없음" || r.못들은이유 === "안됨") { 접기(true); return; } + 못알아들음 += 1; + if (못알아들음 >= 다시묻기한도) { 접기(true); return; } + 한바퀴(); + return; + } + + const 답 = 예아니오(r.들은말, 언어 === "en-US"); + if (답 === true) { 접기(false); 네라고하면(); return; } + // 아니라고 했다. 답을 들은 것이므로 다시 묻지 않는다. + if (답 === false) { 접기(false); 아니라고하면?.(); return; } + + 못알아들음 += 1; + if (못알아들음 >= 다시묻기한도) { 접기(true); return; } + 한바퀴(); + }, { 스스로끝내기: true }); + }); + }; + + 한바퀴(); + return { 그만기다리기: () => 접기(false) }; +}; diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 4f4fab1..72db424 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -20,9 +20,10 @@ import { } from "@/api/account"; import { 연동기록, 팀백엔드모드 } from "@/api/devlog"; import { 접근성설정, 언어목록, type 도움설정, type 언어코드 } from "@/api/a11y"; -import { 소리를낼수있나, 읽어주기, 그만읽기, 화면글 } from "@/api/speech"; +import { 소리를낼수있나, 읽어주기, 그만읽기, 화면글, 다읽을때까지 } from "@/api/speech"; import { 들을수있나, 들어보기, type 못들은이유 } from "@/api/listen"; -import { 말에서고르기, 예아니오 } from "@/api/voice"; +import { 부를때까지기다리기, 말로예아니오받기, 마이크상태보기, 마이크허락받기, type 마이크상태 } from "@/api/wake"; +import { 말에서고르기, 예아니오, 뒤로가자고했나, 다음가자고했나 } from "@/api/voice"; import { 맵기물어보기 } from "@/api/spicy"; import { 입력출처 } from "@/api/inputsource"; import { 가격한도 } from "@/api/budget"; @@ -371,6 +372,99 @@ function WelcomeScreen({ onStart, onLogin, 동의함, on동의, onPrivacy, 소 언어: 언어코드; on언어: (v: 언어코드) => void; }) { + /* + * 부르면 시작한다 — 손을 한 번도 안 대는 길(api/wake.ts). + * + * ── 언제 켜지나 ─────────────────────────────────────────────────────────── + * + * 셋이 다 맞아야 켠다. + * + * ① 소리로 주고받기가 켜져 있다. 이 길을 바라는 사람만 마이크를 연다. + * ② **이미 동의했다.** 동의는 건너뛸 수 없는 관문이라, 부르는 말로 넘어가게 + * 두면 안 된다. 처음 오신 분은 동의부터 하고, 그 뒤로 이 길이 열린다. + * ③ 브라우저가 **묻지 않고** 마이크를 준다. 아직 안 물어본 자리에서 켜면 + * 아무것도 안 했는데 권한 창이 뜬다. + * + * ── 켜졌다는 것을 화면에 밝힌다 ─────────────────────────────────────────── + * + * 듣고 있으면서 말 안 하는 앱을 만들지 않는다. 아래 안내 줄이 그 약속이고, + * 소리로도 읽힌다(data-소리조용을 안 붙인다) — 화면을 못 보는 분에게는 그 + * 줄이 "지금 불러도 된다" 는 유일한 신호다. + * + * 그만뒀을 때도 말해 준다. 조용히 멈추면 사용자는 아직 듣는 줄 알고 계속 + * 부른다. + */ + /* + * 동의를 말로 받는다. + * + * 동의는 건너뛸 수 없는 관문인데 그 관문이 체크박스라, 화면을 못 보는 분은 + * 손을 안 대고는 지날 수 없었다. 부르는 말을 알아듣게 만들어 놓고도 그 + * 앞에서 막혀 있던 셈이다. + * + * 물음은 아래에 글로 띄운다. 소리 안내가 그 글을 읽고, 그 뒤에 답을 듣는다 — + * 화면에 없는 말을 귀로만 들려주면 눈으로 보는 사람과 다른 것을 듣게 된다. + */ + /* + * 마이크가 어떤 상태인지 화면이 알고 있어야 한다. + * + * 여태 '이미 허용됨' 이 아니면 **아무것도 안 하고 아무 말도 안 했다.** 그래서 + * 처음 오신 분에게는 이 길이 있는지조차 안 보였고, 화면은 멀쩡한데 말을 걸어도 + * 반응이 없었다 — 이 앱이 가장 피하려던 자리를 우리가 만든 셈이다. + * + * 이제 상태를 보고 그에 맞는 말을 한다. 아직 안 물어본 자리에는 한 번 물어볼 + * 단추를 내민다. 브라우저는 사람이 누른 자리에서만 물어 주므로 그 단추가 있어야 + * 한다 — 그 한 번이 지나면 다음부터는 손을 안 대도 된다. + */ + const [마이크, set마이크] = useState<마이크상태>("모름"); + /** 허락을 받은 뒤 아래 두 효과를 다시 걸기 위한 세대. */ + const [허락세대, set허락세대] = useState(0); + useEffect(() => { + let 살아있나 = true; + void 마이크상태보기().then((s) => { if (살아있나) set마이크(s); }); + return () => { 살아있나 = false; }; + }, [허락세대]); + + const [동의듣기, set동의듣기] = useState<"안함" | "묻는중" | "그만함">("안함"); + useEffect(() => { + if (동의함 || !소리켜짐 || !소리로주고받나() || 마이크 !== "됨") { set동의듣기("안함"); return; } + let 살아있나 = true; + let 듣던것: { 그만기다리기: () => void } | null = null; + void Promise.resolve(true).then((되나) => { + if (!살아있나 || !되나) return; + set동의듣기("묻는중"); + 듣던것 = 말로예아니오받기(언어, { + 네라고하면: () => { if (살아있나) on동의(true); }, + // 아니라고 하셨다. 다시 묻지 않는다 — 답을 들은 것이지 못 들은 것이 아니다. + 아니라고하면: () => { if (살아있나) set동의듣기("안함"); }, + 그만뒀으면: () => { if (살아있나) set동의듣기("그만함"); }, + }); + }); + return () => { + 살아있나 = false; + 듣던것?.그만기다리기(); + }; + }, [동의함, 소리켜짐, 언어, 마이크, on동의]); + + const [부르기상태, set부르기상태] = useState<"안함" | "기다림" | "그만함">("안함"); + useEffect(() => { + if (!동의함 || !소리켜짐 || !소리로주고받나() || 마이크 !== "됨") { set부르기상태("안함"); return; } + let 살아있나 = true; + let 기다리던것: { 그만기다리기: () => void } | null = null; + void Promise.resolve(true).then((되나) => { + if (!살아있나 || !되나) return; + set부르기상태("기다림"); + 기다리던것 = 부를때까지기다리기(언어, { + 들리면: () => { if (살아있나) onStart(); }, + 그만뒀으면: () => { if (살아있나) set부르기상태("그만함"); }, + }); + }); + return () => { + 살아있나 = false; + 기다리던것?.그만기다리기(); + }; + // 언어가 바뀌면 인식 언어도 바뀌어야 하므로 다시 건다. + }, [동의함, 소리켜짐, 언어, 마이크, onStart]); + return (
{/* @@ -419,6 +513,73 @@ function WelcomeScreen({ onStart, onLogin, 동의함, on동의, onPrivacy, 소 확인하셔야 시작할 수 있어요

)} + {/* + 말로 답할 수 있다고 알린다. + + data-소리조용 을 안 붙인다 — 소리로 읽혀야 이 길이 열린다. 화면을 못 + 보는 분에게는 이 줄이 "말해도 된다" 는 유일한 신호다. + + "서버로 한 번 전송됩니다" 를 같이 적는다. 동의를 말로 받으려면 그 + 한마디가 동의 전에 서버를 다녀와야 하는데(녹음을 보내 인식한다), + 순서가 뒤집히는 것을 숨기지 않는다. + */} + {/* + 마이크를 아직 안 물어본 자리. **한 번 물어볼 단추를 내민다.** + + 여태 여기서 아무것도 안 했다. 그래서 처음 오신 분에게는 말로 시작하는 + 길이 있는지조차 안 보였고, 말을 걸어도 반응이 없었다 — 화면은 멀쩡한데 + 아무 일도 안 일어나면 사람은 앱이 고장 났다고 여긴다. + + 브라우저는 **사람이 누른 자리에서만** 마이크를 물어 준다. 그래서 이 + 단추가 필요하다. 한 번 허락하면 다음부터는 손을 안 대도 된다. + */} + {!동의함 && 소리켜짐 && (마이크 === "물어봐야함" || 마이크 === "모름") && ( + <> +

+ 말로 답하시려면 마이크를 한 번 허락해 주세요. 허락하시면 다음부터는 손대지 않고 시작할 수 있어요. +

+ { void 마이크허락받기().then(() => set허락세대((n) => n + 1)); }}> + 마이크 허락하기 + + + )} + {/* + 막아 두었다. 우리가 다시 물을 수 없는 자리라, 어디서 푸는지 알려 준다 — + "안 됩니다" 만 말하고 끝내면 사용자는 할 수 있는 일이 없다. + */} + {!동의함 && 소리켜짐 && 마이크 === "막힘" && 들을수있나() && ( +

+ 마이크가 막혀 있어 말로는 답할 수 없어요. 주소창 옆 자물쇠에서 마이크를 허용하시면 됩니다. +

+ )} + {동의듣기 === "묻는중" && ( +

+ 이용에 동의하시겠어요? 동의하시면 "네" 라고 말씀해 주세요. 대답은 알아듣기 위해 서버로 한 번 전송됩니다. +

+ )} + {동의듣기 === "그만함" && ( +

+ 잘 못 알아들어서 그만 들을게요. 위 확인란을 눌러 주세요. +

+ )} + {/* + 듣고 있으면서 말 안 하는 앱을 만들지 않는다. + + data-소리조용 을 안 붙인다 — 소리로도 읽혀야 한다. 화면을 못 보는 + 분에게는 이 줄이 "지금 불러도 된다" 는 유일한 신호다. + + 그만뒀을 때도 말한다. 조용히 멈추면 아직 듣는 줄 알고 계속 부른다. + */} + {부르기상태 === "기다림" && ( +

+ 부르는 말을 기다리고 있어요. "키오브릿지" 라고 말씀하시면 손대지 않고 시작해요. +

+ )} + {부르기상태 === "그만함" && ( +

+ 이제 부르는 말을 듣고 있지 않아요. 아래 "바로 시작하기" 를 눌러 주세요. +

+ )} {/* 주 버튼 = 익명 시작. 가입도 로그인도 요구하지 않는다. */} @@ -1096,7 +1257,7 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { * 제대로 받는다. */ const [상태, set상태] = useState<"쉬는중" | "듣는중" | "처리중">("쉬는중"); - const [못들음, set못들음] = useState<못들은이유 | "못골랐어요" | null>(null); + const [못들음, set못들음] = useState<못들은이유 | "못골랐어요" | "첫질문이에요" | null>(null); /* * 서버가 "이 둘 중 하나 같은데 확실치 않다" 고 할 때 되물을 값들(팀 #133). * @@ -1106,6 +1267,35 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { const [되물을것, set되물을것] = useState(null); const 듣던것 = useRef<{ 그만두기: (보내기?: boolean) => void } | null>(null); const 회차 = useRef(0); + /* + * 이어 듣기 — 한 번 '말하기' 를 누르면 그 뒤로는 손을 안 댄다. + * + * 말이 끝나면 알아서 보내고(api/vad.ts), 답이 들어가면 다음 질문을 읽어 준 + * 뒤 다시 듣기 시작한다. 다섯 칸이면 열 번 눌러야 하던 것이 한 번이 된다. + * 화면을 못 보는 분에게는 그 열 번이 답하는 일보다 오래 걸리는 장벽이었다. + * + * ref 로 둔다 — 이 값이 바뀌었다고 화면을 다시 그릴 일이 없고, 늦게 도착한 + * 콜백 안에서도 지금 값을 봐야 한다(state 면 그 콜백이 만들어질 때의 옛 값을 + * 본다). + */ + const 이어서 = useRef(false); + /* + * 이어 듣기가 헛도는 것을 막는다. + * + * 사용자가 자리를 뜨면 "말이 없다 → 다시 듣기" 가 끝없이 돈다 — 마이크가 + * 계속 켜져 있고 배터리도 닳는다. 잇달아 두 번 못 들으면 이어 듣기를 끄고 + * 단추를 남긴다. 그때부터는 사람이 다시 시작하는 길로 돌아간다. + */ + const 잇단실패 = useRef(0); + /** 화면을 떠난 뒤 늦게 도착한 예약이 마이크를 다시 열면 안 된다. */ + const 살아있나 = useRef(true); + /* + * "다음 듣기를 걸어야 한다" 는 표시. 실제로 거는 일은 아래 useEffect 가 한다. + * + * state 로 두는 이유는 이어서예약() 주석에 있다 — 요약하면, 칸이 새로 그려진 + * 뒤에 걸어야 옛 축으로 듣지 않는다. + */ + const [예약, set예약] = useState(false); /* * 화면을 떠나면 듣던 것을 멈춘다. 안 멈추면 마이크가 계속 켜져 있다. @@ -1115,6 +1305,9 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { * 회차도 올려서, 이미 서버로 나간 요청이 뒤늦게 돌아와도 무시된다. */ useEffect(() => () => { + // 이어 듣기 예약이 화면을 떠난 뒤 깨어나 마이크를 다시 열면 안 된다. + 살아있나.current = false; + 이어서.current = false; 회차.current += 1; 듣던것.current?.그만두기(false); }, []); @@ -1145,8 +1338,42 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { set못들음(null); // 앞 축에 대한 되물음이 다음 축까지 따라오면 안 된다. set되물을것(null); - if (마지막인가) { onDone(); return; } + if (마지막인가) { + // 흐름이 끝났다. 이어 듣기도 여기서 끈다 — 안 끄면 이름 칸으로 넘어간 + // 뒤에도 마이크가 다시 열린다. + 이어서.current = false; + onDone(); + return; + } set칸((n) => n + 1); + 이어서예약(); + }; + + /* + * 앞 질문으로 되돌아간다. + * + * 여태 앞으로 가는 길만 있었다 — 말하기·건너뛰기뿐이라, 두 번째 칸에 잘못 + * 답하고 나면 되돌릴 방법이 없었다. 화면 뒤로가기는 흐름을 통째로 나가므로 + * 되돌리기가 아니라 포기다. + * + * 답은 지우지 않는다. 돌아간 칸에 이미 고른 값이 칩으로 눌려 있어야, 무엇을 + * 골랐었는지 보고 고칠 수 있다. 다시 답하면 그 값으로 덮인다(여러 개 고르는 + * 칸은 이어 붙는데, 그 칸에 머물러 있을 때와 같은 규칙이다). + * + * 듣던 녹음은 부르는 쪽에서 끊는다 — 단추로 왔으면 아직 듣는 중일 수 있고, + * 말로 왔으면 이미 끝나 있다. + */ + const 앞칸으로 = () => { + set못들음(null); + set되물을것(null); + set칸((n) => Math.max(0, n - 1)); + // 되돌아온 질문도 다시 물어야 한다. 안 그러면 여기서 흐름이 멎는다. + 이어서예약(); + }; + + const 앞칸단추 = () => { + if (상태 !== "쉬는중") 듣기취소(); + 앞칸으로(); }; // 고른 값을 넣고 다음 칸으로. 여러 개 고르는 칸은 이어 붙이고 그 자리에 머문다. @@ -1156,10 +1383,74 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { set되물을것(null); const 이전 = 값[지금축.label] ?? []; on고르기(지금축.label, 지금축.multi ? [...new Set([...이전, 고른것])] : [고른것], 말로); - if (지금축.multi) set못들음(null); - else 다음으로(); + if (지금축.multi) { + set못들음(null); + // 여러 개 고르는 칸은 그 자리에 머문다. 더 말할 수 있게 다시 연다 — + // 안 열면 이어 듣기가 이 칸에서만 멎는다. + 이어서예약(); + } else 다음으로(); }; + /* + * 다음 듣기를 예약한다. 이어 듣기가 켜져 있을 때만 움직인다. + * + * 바로 안 연다. 칸이 바뀌면 화면 글이 바뀌고, 소리 안내가 그 새 글을 읽기 + * 시작한다(App 의 감시가 조금 늦게 잡는다). 그 읽기가 끝나기 전에 마이크를 + * 열면 두 가지가 어긋난다 — 듣기시작() 이 읽던 것을 끊어서 무엇을 묻는지 + * 못 듣게 되고, 안 끊더라도 스피커 소리가 녹음에 실린다. + * + * 그래서 감시가 읽기를 걸 시간을 잠깐 주고, 그 읽기가 다 끝나기를 기다린다. + * + * ── 여기서 setTimeout 을 직접 걸면 안 된다 ───────────────────────────────── + * + * 이 함수는 **어느 한 렌더의 함수**다. 여기서 건 타이머의 콜백은 그 렌더의 + * 듣기시작·지금축·칸 을 그대로 쥔다. 그런데 이 함수를 부르는 자리는 방금 + * set칸 을 올린 자리다 — 700ms 뒤 깨어난 콜백은 **바뀌기 전 축**으로 듣기를 + * 시작한다. + * + * 그러면 화면은 k+1 번째를 묻는데 사용자의 답은 k 번째 축에 들어간다. + * 회차 가드도 이걸 못 막는다. 듣기시작() 이 스스로 회차를 올리고 그 값으로 + * 시작하기() 를 부르므로 언제나 같기 때문이다. + * + * 이 앱이 가장 피해야 할 결함이다 — 사용자가 고르지 않은 조건이 주문표에 + * 들어간다. 그래서 예약은 표시만 남기고, 실제로 거는 일은 칸이 새로 그려진 + * 뒤 아래 useEffect 가 한다. + */ + const 이어서예약 = () => { + if (!이어서.current) return; + set예약(true); + }; + + /* + * 예약이 걸려 있으면, **칸이 새로 그려진 뒤에** 다음 듣기를 시작한다. + * + * 칸을 의존성에 둔다. 그래야 이 effect 안의 듣기시작 이 새 렌더의 것이고, + * 새 축으로 듣는다. 정리 함수가 타이머도 걷어 간다 — 화면을 떠나거나 칸이 + * 또 바뀌면 지난 예약은 사라진다. + */ + useEffect(() => { + if (!예약) return; + const 내회차 = 회차.current; + const 표 = setTimeout(() => { + if (!살아있나.current || !이어서.current) return; + // 그새 사람이 손을 댔다(손으로 고르기·건너뛰기·앞 질문). 그쪽이 새 회차를 + // 만들었으므로 이 예약은 지난 것이다. + if (내회차 !== 회차.current) return; + /* + * 안내가 아직 시작도 안 했을 수 있다. 시작기다림 을 줘서, 그 동안에는 + * '다 읽었다' 로 접지 않는다 — 안 그러면 마이크가 안내보다 먼저 열린다. + */ + void 다읽을때까지(8000, { 시작기다림: 1200 }).then(() => { + if (!살아있나.current || !이어서.current) return; + if (내회차 !== 회차.current) return; + set예약(false); + 듣기시작(); + }); + }, 700); + return () => clearTimeout(표); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [예약, 칸]); + const 듣기시작 = () => { /* * 듣기 시작하는 순간 스피커부터 조용히 시킨다. @@ -1173,17 +1464,71 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { set되물을것(null); set상태("듣는중"); 회차.current += 1; + /* + * 여기서부터 이어 듣기가 켜진다. + * + * 한 번 '말하기' 를 누른 사람은 말로 답할 뜻이 있는 것이다. 그 뒤로는 + * 말이 끝나면 알아서 보내고 다음 질문으로 이어 간다 — 칸마다 두 번씩 + * 단추를 찾아 누르지 않아도 된다. + */ + 이어서.current = true; // 단추를 누른 바로 이 자리에서 듣기 시작한다 — 브라우저는 사람이 누른 자리가 // 아니면 마이크를 안 열어 준다. 시작하기(회차.current, 지금축); }; const 시작하기 = (내회차: number, 이축: DetailOption) => { + /* + * 이어 듣기 중이면 말이 끝나는 것을 기기 안에서 지켜보다가 알아서 보낸다 + * (api/vad.ts). 처음 한 번은 사람이 '말하기' 를 눌러 여기 오지만, 그 뒤로는 + * 이 값이 켜져 있어 손을 안 대도 이어진다. + */ 듣던것.current = 들어보기(언어, (r) => { if (내회차 !== 회차.current) return; 듣던것.current = null; set상태("쉬는중"); - if (!("들은말" in r)) { set못들음(r.못들은이유); return; } + if (!("들은말" in r)) { + set못들음(r.못들은이유); + /* + * 못 들었다. 이어 듣기 중이면 한 번 더 열어 준다 — 말을 더듬거나 + * 주위가 시끄러웠을 뿐인데 거기서 멈추면, 화면을 못 보는 분은 다시 + * 시작할 단추를 찾아야 한다. + * + * 다만 마이크가 막혔으면(권한없음) 몇 번을 열어도 같다. 그때는 바로 + * 접고, 화면이 "손으로 골라 주세요" 라고 말한다. + */ + 잇단실패.current += 1; + if (r.못들은이유 === "권한없음" || 잇단실패.current >= 2) { 이어서.current = false; return; } + 이어서예약(); + return; + } + 잇단실패.current = 0; + + /* + * 무엇보다 먼저 — 앞 질문으로 가 달라는 말인가. + * + * 답으로 읽기 전에 본다. 뒤에 두면 "뒤로" 가 어느 보기와 우연히 겹칠 때 + * 되돌아가는 대신 값이 들어간다. 화면을 못 보는 분에게는 이 말이 앞 칸을 + * 고칠 유일한 길이라, 그 길이 답 맞추기보다 뒤에 있으면 안 된다. + * + * 첫 칸에서는 갈 곳이 없다. 그때는 못 알아들은 것으로 두지 않고 그렇게 + * 말해 준다 — 아무 일도 안 일어나면 사용자는 앱이 못 들었다고 여긴다. + */ + if (뒤로가자고했나(r.들은말, 언어 === "en-US")) { + if (칸 === 0) { set못들음("첫질문이에요"); 이어서예약(); return; } + 앞칸으로(); + return; + } + + /* + * 다음으로 가 달라는 말인가. 이것도 답보다 먼저 본다. + * + * 여러 개 고르는 칸(카페의 시럽)에서는 이 말이 없으면 나갈 길이 없다 — + * 값을 넣어도 그 자리에 머무는 칸이라, 손을 안 쓰는 분은 같은 질문만 + * 되풀이해 듣다가 이어 듣기가 꺼진다. 화면의 '건너뛰기' 는 그분들에게 + * 없는 단추다. + */ + if (다음가자고했나(r.들은말, 언어 === "en-US")) { 다음으로(); return; } // 첫째 길 — 보기 이름을 그대로 말했나. 기존 맞추기를 그대로 쓴다. const 고른값 = 말에서고르기(r.들은말, place, 언어 === "en-US").고른값[이축.label]; @@ -1214,20 +1559,22 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { */ if (이축.label === "맵기") { set상태("처리중"); - void 맵기물어보기(r.들은말, 언어 === "en-US").then((결과) => { + void 맵기물어보기(r.들은말).then((결과) => { if (내회차 !== 회차.current) return; set상태("쉬는중"); if ("고른값" in 결과 && 이축.choices.includes(결과.고른값)) { 넣기(결과.고른값, true); return; } if ("되물을것" in 결과) { const 있는것 = 결과.되물을것.filter((v) => 이축.choices.includes(v)); - if (있는것.length > 0) { set되물을것(있는것); return; } + if (있는것.length > 0) { set되물을것(있는것); 이어서예약(); return; } } set못들음("못골랐어요"); + 이어서예약(); }); return; } set못들음("못골랐어요"); - }); + 이어서예약(); + }, { 스스로끝내기: 이어서.current }); }; /* @@ -1358,9 +1705,12 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: {

{못들음 === "권한없음" ? "마이크를 쓸 수 없어요. 위에서 손으로 골라 주세요." - : 못들음 === "못골랐어요" - ? "말씀은 들었는데 어느 쪽인지 못 골랐어요. 다시 말씀해 주시거나 위에서 골라 주세요." - : "잘 안 들렸어요. 다시 말씀해 주세요."} + : 못들음 === "첫질문이에요" + // 아무 일도 안 일어나면 못 들은 줄 안다. 들었고 갈 곳이 없다고 말해 준다. + ? "여기가 첫 질문이라 더 앞으로는 갈 수 없어요." + : 못들음 === "못골랐어요" + ? "말씀은 들었는데 어느 쪽인지 못 골랐어요. 다시 말씀해 주시거나 위에서 골라 주세요." + : "잘 안 들렸어요. 다시 말씀해 주세요."}

)} @@ -1372,7 +1722,9 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { */} {상태 === "듣는중" && (

- 듣고 있어요. 말씀하신 뒤 "그만 듣기"를 눌러 주세요. + {t(이어서.current + ? "듣고 있어요. 말씀이 끝나면 알아서 다음으로 넘어가요." + : "듣고 있어요. 말씀하신 뒤 \"그만 듣기\"를 눌러 주세요.")}

)} {상태 === "처리중" && ( @@ -1381,10 +1733,20 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: {

)} -
+
{상태 === "듣는중" ? "그만 듣기" : 상태 === "처리중" ? "인식 중…" : "말하기"} + {/* + 앞 질문. 첫 칸에서는 갈 곳이 없어 아예 안 내민다 — 눌러도 아무 일이 + 없는 단추를 두면 눌러 본 사람이 앱이 멎었다고 여긴다. + + 말로도 갈 수 있다("뒤로"·"이전"). 손을 안 쓰는 분에게는 그쪽이 + 유일한 길이고, 이 단추는 눈으로 보고 누르는 분을 위한 같은 문이다. + */} + {칸 > 0 && ( + 앞 질문 + )} {/* 건너뛰기를 늘 둔다. 답하고 싶지 않은 칸에서 갇히면 안 된다. */} {마지막인가 ? "끝내기" : "건너뛰기"}
@@ -2500,13 +2862,25 @@ function PairingFailed({ reason = "유효하지 않은 QR입니다", onScan }: { ); } -function PairingExpired({ onScan }: { onScan: () => void }) { +/** + * 연결이 끝난 화면. + * + * 끝나는 길이 둘이라 말을 나눈다 — 시간이 지난 것과, 한 번 써서 끝난 것. + * 취소하고 나온 사람에게 "연결 시간이 만료되었습니다" 라고 하면 사실이 아니고, + * 사용자는 자기가 무엇을 오래 붙잡고 있었나 되짚게 된다(팀 #146). + * + * 어느 쪽이든 할 일은 같다(다시 찍기). 다른 것은 그 앞에 무슨 말이 적히느냐 + * 뿐이고, 그게 맞는 말이어야 한다 — listen.ts 의 못들은이유 와 같은 판단이다. + */ +function PairingExpired({ onScan, 이유 = "만료" }: { onScan: () => void; 이유?: "만료" | "다썼음" }) { return (
} - title={<>연결 시간이
만료되었습니다} - desc="안전을 위해 연결이 종료되었어요" + title={이유 === "다썼음" ? <>이 연결은
다 쓰셨어요 : <>연결 시간이
만료되었습니다} + desc={이유 === "다썼음" + ? "한 번 연결하면 한 번 주문할 수 있어요" + : "안전을 위해 연결이 종료되었어요"} />
@@ -2680,11 +3054,13 @@ function PairingIdle({ onScan }: { onScan: () => void }) { ); } -function QrScreen({ onPaired, initialPhase = "scan", connected = null }: { +function QrScreen({ onPaired, initialPhase = "scan", 끝난이유 = "만료", connected = null }: { onPaired: (pairingId: string, expiresAt: number, kioskName: string) => void; // 연결이 만료돼서 되돌아온 경우에는 스캐너가 아니라 만료 안내부터 보여 준다. // 스캐너로 바로 보내면 사용자는 자기가 왜 여기 왔는지 알 수 없다. initialPhase?: "scan" | "expired"; + /** 연결이 왜 끝났는지. 만료 화면의 문구가 이걸 따라 갈린다(팀 #146). */ + 끝난이유?: "만료" | "다썼음"; // 이미 연결돼 있으면 그 상태를 그대로 보여 준다. 다시 찍으라고 하지 않는다. connected?: { pairingId: string; expiresAt: number; kioskName: string } | null; }) { @@ -2775,7 +3151,7 @@ function QrScreen({ onPaired, initialPhase = "scan", connected = null }: { /> )} {phase === "failed" && } - {phase === "expired" && } + {phase === "expired" && }
); @@ -3282,7 +3658,7 @@ function 도움설정말로채우기({ 언어, 설정, onChange, onDone }: { {/* 녹음은 스스로 안 끝난다 — 한칸씩말하기 의 같은 안내와 같은 이유. */} {상태 === "듣는중" && (

- 듣고 있어요. 말씀하신 뒤 "그만 듣기"를 눌러 주세요. + {t("듣고 있어요. 말씀하신 뒤 \"그만 듣기\"를 눌러 주세요.")}

)} {상태 === "처리중" && ( @@ -4187,11 +4563,19 @@ function OrderMappingLoading() { } function OrderConfirmScreen({ - pairingId, sheet, onBack, onApproved, + pairingId, sheet, onBack, on연결끝남, onApproved, }: { pairingId: string; sheet: OrderSheet; onBack: () => void; + /** + * 거절해서 이 연결을 다 썼다. + * + * onBack 과 갈라 둔다. 머리의 뒤로가기는 그냥 나가는 것이라 연결이 살아 + * 있지만, 거절은 서버가 그 pairing 을 폐기하는 자리다(팀 #146). 둘을 한 + * 콜백으로 묶으면 그냥 나간 사람의 연결까지 끊게 된다. + */ + on연결끝남: () => void; onApproved: (planId: string) => void; }) { const [mapping, setMapping] = useState(null); @@ -4254,7 +4638,14 @@ function OrderConfirmScreen({ * 사용자가 감당할 일이 아니다. */ const 거절하기 = () => { - onBack(); + /* + * 되돌아가는 것으로 끝내지 않고, 이 연결이 끝났다는 것까지 알린다. + * + * 서버는 거절도 승인과 같은 경로로 처리해 pairing 을 폐기한다. 프론트가 + * 그 값을 계속 들고 있으면, 다른 주문표로 들어갈 때 죽은 연결로 매핑을 + * 시도해 "연결 정보를 찾을 수 없습니다" 라는 개발자 말이 뜬다(팀 #146). + */ + on연결끝남(); void api.reject({ pairingId, sheetId: sheet.id }).catch(() => {}); }; @@ -5278,6 +5669,13 @@ export default function App() { const [pairingKiosk, setPairingKiosk] = useState(null); // 만료 때문에 QR 화면으로 되돌아왔는지. 되돌아왔으면 안내부터 띄운다. const [qrExpired, setQrExpired] = useState(false); + /* + * 연결이 왜 끝났나. 화면 문구가 이걸 따라 갈린다(PairingExpired). + * + * 취소하고 나온 사람에게 "연결 시간이 만료되었습니다" 라고 하면 사실이 + * 아니다 — 시간은 아직 남아 있었고, 다 쓴 것이다(팀 #146). + */ + const [qr끝난이유, setQr끝난이유] = useState<"만료" | "다썼음">("만료"); const [orderSheet, setOrderSheet] = useState(null); /** * 지금 고치고 있는 주문표. null 이면 새로 만드는 중이다. @@ -5710,6 +6108,7 @@ export default function App() { if (접근성값.mobilitySupport) return; const 남은 = pairingExpiresAt - Date.now(); const 되돌리기 = () => { + setQr끝난이유("만료"); setPairingId(null); setPairingExpiresAt(null); setPairingKiosk(null); @@ -6155,6 +6554,7 @@ export default function App() { setScreen("saved")} + /* + * 거절하면 서버가 이 pairing 을 폐기한다. 프론트도 같이 놓아야 + * 다른 주문표로 들어갈 때 죽은 연결로 매핑하지 않는다(팀 #146). + * + * 만료될 때와 같은 정리다 — 다른 것은 화면에 적히는 이유뿐이다. + */ + on연결끝남={() => { + setQr끝난이유("다썼음"); + setPairingId(null); + setPairingExpiresAt(null); + setPairingKiosk(null); + setOrderSheet(null); + setFromQr(false); + setScreen("saved"); + setTab("qr"); + setQrExpired(true); + setQrKey((k) => k + 1); + }} onApproved={(id) => { setPlanId(id); setScreen("execution"); }} /> )} diff --git a/frontend/src/i18n/en.test.ts b/frontend/src/i18n/en.test.ts new file mode 100644 index 0000000..4684b07 --- /dev/null +++ b/frontend/src/i18n/en.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { EN } from "./en"; + +/* + * 이 파일이 지키는 것 — **화면에 새로 나가는 우리말이 표에서 빠지지 않는다.** + * + * ── 왜 t() 를 세지 않고 표를 세나 ─────────────────────────────────────────── + * + * 이 앱은 문장마다 t() 를 두르지 않는다. 화면을 그린 뒤 DOM 을 훑어 한 번에 + * 바꾼다(i18n/apply.ts 머리말에 그 판단이 적혀 있다). 화면에 나가는 우리말이 + * 600줄 가까이 되고 App.tsx 한 파일에 흩어져 있어서, 손으로 두르면 빠뜨리는 + * 자리가 생기고 그 자리는 화면을 열어 보기 전까지 아무도 모르기 때문이다. + * + * 그래서 "옮겨졌나" 는 t() 가 붙었는지로 볼 수 없다. **표에 그 문장이 있는지**로 + * 봐야 한다 — 있으면 옮겨지고, 없으면 우리말로 남는다. + * + * 실제로 App.tsx 는 이렇게 쓴다(1360행 근처): + * + * 못들음 === "권한없음" + * ? "마이크를 쓸 수 없어요. 위에서 손으로 골라 주세요." ← t() 없음 + * : ... + * + * 그리고 그 문장은 이 표에 있다. 이 파일은 그 규칙을 사람 눈 대신 지킨다. + */ + +/** 열쇠가 있고, 옮긴 말에 우리말이 안 남았는지. */ +const 옮겨지나 = (문장: string) => { + const 영어 = EN[문장]; + expect(영어, `표에 없음: ${문장}`).toBeTruthy(); + expect(영어, `옮긴 말에 우리말이 남음: ${문장}`).not.toMatch(/[가-힣]/); +}; + +describe("한 칸씩 말하기 — 되돌아가기와 이어 듣기", () => { + it("앞 질문 단추와 첫 질문 안내가 표에 있다", () => { + 옮겨지나("앞 질문"); + 옮겨지나("여기가 첫 질문이라 더 앞으로는 갈 수 없어요."); + }); + + it("듣는 중 안내 두 줄이 표에 있다", () => { + // 이어 듣기냐 아니냐로 문장이 갈린다. 한쪽만 넣으면 그 갈래에서만 우리말이 남는다. + 옮겨지나("듣고 있어요. 말씀하신 뒤 \"그만 듣기\"를 눌러 주세요."); + 옮겨지나("듣고 있어요. 말씀이 끝나면 알아서 다음으로 넘어가요."); + }); +}); + +describe("부르면 시작하기", () => { + it("듣고 있다는 안내와 그만뒀다는 안내가 표에 있다", () => { + /* + * 이 두 줄은 소리로도 읽힌다(data-소리조용 을 안 붙였다). 화면을 못 보는 + * 분에게는 "지금 불러도 된다" 는 유일한 신호라, 영어 화면에서 우리말로 + * 남으면 영어를 쓰는 분에게는 그 신호가 통째로 없는 셈이 된다. + */ + 옮겨지나("부르는 말을 기다리고 있어요. \"키오브릿지\" 라고 말씀하시면 손대지 않고 시작해요."); + 옮겨지나("이제 부르는 말을 듣고 있지 않아요. 아래 \"바로 시작하기\" 를 눌러 주세요."); + }); +}); + +describe("말로 받는 동의", () => { + it("물음과 그만둠 안내가 표에 있다", () => { + /* + * 이 줄은 체크박스를 못 찾는 분이 지날 수 있는 유일한 문이다. 영어 화면에서 + * 우리말로 남으면 영어를 쓰는 분에게는 그 문이 통째로 없는 셈이 된다. + */ + 옮겨지나("이용에 동의하시겠어요? 동의하시면 \"네\" 라고 말씀해 주세요. 대답은 알아듣기 위해 서버로 한 번 전송됩니다."); + 옮겨지나("잘 못 알아들어서 그만 들을게요. 위 확인란을 눌러 주세요."); + }); + + it("물음이 '서버로 전송' 을 밝힌다", () => { + /* + * 동의를 말로 받으려면 그 한마디가 동의 전에 서버를 다녀와야 한다. + * 순서가 뒤집히는 것을 숨기지 않기로 했다 — 이 문장에서 그 말이 빠지면 + * 사용자는 모르는 채로 말하게 된다. + */ + const 물음 = "이용에 동의하시겠어요? 동의하시면 \"네\" 라고 말씀해 주세요. 대답은 알아듣기 위해 서버로 한 번 전송됩니다."; + expect(물음).toContain("서버로"); + expect(EN[물음]).toContain("sent once"); + }); +}); + +describe("마이크를 아직 안 물어본 자리", () => { + it("허락 안내와 단추, 막힘 안내가 표에 있다", () => { + /* + * 여태 이 자리에서 아무것도 안 했다. 처음 오신 분에게는 말로 시작하는 길이 + * 있는지조차 안 보였고, 말을 걸어도 반응이 없었다 — 화면은 멀쩡한데 아무 + * 일도 안 일어나면 사람은 앱이 고장 났다고 여긴다. + */ + 옮겨지나("말로 답하시려면 마이크를 한 번 허락해 주세요. 허락하시면 다음부터는 손대지 않고 시작할 수 있어요."); + 옮겨지나("마이크 허락하기"); + 옮겨지나("마이크가 막혀 있어 말로는 답할 수 없어요. 주소창 옆 자물쇠에서 마이크를 허용하시면 됩니다."); + }); + + it("막힘 안내가 어디서 푸는지까지 말한다", () => { + // "안 됩니다" 만 말하고 끝내면 사용자는 할 수 있는 일이 없다. + const 막힘 = "마이크가 막혀 있어 말로는 답할 수 없어요. 주소창 옆 자물쇠에서 마이크를 허용하시면 됩니다."; + expect(막힘).toContain("자물쇠"); + expect(EN[막힘]).toContain("lock icon"); + }); +}); + +describe("표 자체가 성한가", () => { + it("자리표시자가 원문과 옮긴 말에서 같다", () => { + /* + * tf() 는 원문에 있는 이름으로만 값을 갈아 끼운다. 옮긴 말에 다른 이름이 + * 들어 있으면 그 자리는 영영 안 채워져, 화면에 `{것들}` 이 글자 그대로 + * 뜬다. 빠져 있으면 반대로 값이 통째로 사라진다. + */ + const 이름들 = (문장: string) => (문장.match(/\{[^}]*\}/g) ?? []).slice().sort(); + const 어긋난것 = Object.entries(EN) + .filter(([k, v]) => 이름들(k).join("|") !== 이름들(v).join("|")) + .map(([k, v]) => `${k} → ${v}`); + expect(어긋난것).toEqual([]); + }); + + it("옮긴 말에 우리말이 섞인 항목이 없다", () => { + /* + * 반쯤 옮긴 문장은 안 옮긴 문장보다 나쁘다 — 무엇이 빠졌는지 안 보인다. + * + * 자리표시자 이름은 뺀다. `{전체}`·`{것들}` 처럼 우리말로 지어 두었는데, + * 그건 화면에 안 나가고 tf() 가 값으로 갈아 끼우는 자리다. + * + * **원문에 있는 이름만** 뺀다. `{...}` 를 통째로 지우면, 원문에 없던 + * `{한국어}` 같은 것을 옮긴 말에 적어 두어도 이 검사를 지나간다 — 그리고 + * 그건 tf() 가 못 채우므로 영어 화면에 우리말이 그대로 뜬다. + */ + const 섞인것 = Object.entries(EN) + .filter(([k, v]) => { + const 아는이름 = new Set(k.match(/\{[^}]*\}/g) ?? []); + const 남은말 = v.replace(/\{[^}]*\}/g, (이름) => (아는이름.has(이름) ? "" : 이름)); + return /[가-힣]/.test(남은말); + }) + .map(([k, v]) => `${k} → ${v}`); + expect(섞인것).toEqual([]); + }); + + it("빈 값으로 둔 항목이 없다", () => { + // 빈 값이면 화면에서 그 자리가 통째로 사라진다. 우리말로 남는 것보다 나쁘다. + const 빈것 = Object.entries(EN).filter(([, v]) => v.trim() === "").map(([k]) => k); + expect(빈것).toEqual([]); + }); +}); diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 4317dee..d64d560 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -50,6 +50,37 @@ export const EN: Record = { "그만 듣기": "Stop listening", "건너뛰기": "Skip", "끝내기": "Done", + // 앞 질문으로 되돌아가는 단추. 말로도 갈 수 있다("back"·"previous"). + "앞 질문": "Previous question", + // 마이크를 아직 안 물어본 자리 / 막힌 자리. 아무 말 없이 두지 않는다. + "말로 답하시려면 마이크를 한 번 허락해 주세요. 허락하시면 다음부터는 손대지 않고 시작할 수 있어요.": + "To answer by voice, allow the microphone once. After that you can start without a tap.", + "마이크 허락하기": "Allow microphone", + "마이크가 막혀 있어 말로는 답할 수 없어요. 주소창 옆 자물쇠에서 마이크를 허용하시면 됩니다.": + "The microphone is blocked, so you cannot answer by voice. Allow it from the lock icon next to the address bar.", + /* + * 동의를 말로 받는 길(api/wake.ts 의 말로예아니오받기). 체크박스를 못 찾는 + * 분이 지날 수 있는 유일한 문이라, 이 줄은 소리로도 읽힌다. + */ + "이용에 동의하시겠어요? 동의하시면 \"네\" 라고 말씀해 주세요. 대답은 알아듣기 위해 서버로 한 번 전송됩니다.": + "Do you agree to use this service? Say \"yes\" to agree. Your answer is sent once to be recognized.", + "잘 못 알아들어서 그만 들을게요. 위 확인란을 눌러 주세요.": + "We could not catch that, so we stopped listening. Please tick the box above.", + /* + * 듣는 중 안내 두 줄. 화면에 그대로 박혀 있어서 영어 화면에도 우리말이 + * 남아 있었다(#145 리뷰). 이어 듣기냐 아니냐로 문장이 갈린다. + */ + "듣고 있어요. 말씀하신 뒤 \"그만 듣기\"를 눌러 주세요.": + "Listening. When you're done speaking, press \"Stop listening\".", + "듣고 있어요. 말씀이 끝나면 알아서 다음으로 넘어가요.": + "Listening. When you stop speaking, we'll move on by ourselves.", + // 부르면 시작하는 길(api/wake.ts). 듣고 있다는 것을 화면이 반드시 밝힌다. + "부르는 말을 기다리고 있어요. \"키오브릿지\" 라고 말씀하시면 손대지 않고 시작해요.": + "Waiting for you to call. Say \"KioBridge\" and we'll start without a tap.", + "이제 부르는 말을 듣고 있지 않아요. 아래 \"바로 시작하기\" 를 눌러 주세요.": + "We're no longer listening for your call. Please press \"Start now\" below.", + "여기가 첫 질문이라 더 앞으로는 갈 수 없어요.": + "This is the first question, so there is nothing before it.", // 이 기기에 남길지 묻는 화면. 비로그인일 때만 지난다. "저장할까요?": "Save this?",