본문 바로가기
C.W.K.
Lesson 05 of 05 · published

감지하되 몰래는 아니게, 낯선 사람 목소리는 사적으로

~13 min · 통역, 불변식, 프라이버시, 오디오

Level 0첫 단어
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

이 레인이 굽히는 규칙 둘

Spark엔 말소리에 관한 불변식이 둘 있고, 둘 다 아빠가 실제로 쓰는 방식에서 나왔어. 통역사는 아빠가 정한 대로 이 레인 안에서만 둘 다 굽혀. 예외는 예외 자체만큼이나 울타리를 어떻게 쳤는지가 중요해. 그래서 이 레슨은 그 울타리를 읽어.

불변식 7: 언어는 고르고, 몰래 감지하지 않는다

Spark 다른 곳에선 말의 언어를 늘 골라. 아빠 받아쓰기는 아빠가 고른 한국어나 영어를 따라가. 가족 규칙이야. 아빠가 답 마이크를 상대한테 내밀면, 아빠가 바꾸지 않는 한 장면 언어로 들어. 답을 받는 라우트는 언어 없는 녹음은 아예 안 받아. 테스트가 언어 없이 하나 보내 보고 HTTP 422를 기대해. 틀리게 짐작하고도 아무 말 안 하는 감지기는 자신만만한 헛소리를 만들고, 테이블에 앉은 누구도 그걸 알아챌 수 없어.

통역사는 그렇게 못 돌아. 테이블의 누구든 아무 순서로나 말하니까 언어를 미리 고를 사람이 없어. 그래서 아빠는 여기서만 감지를 허락했어. 조건은 이거야. Scribe가 언어 이름을 대고, 그게 뜻하는 방향은 번역보다 먼저 보여주고, 탭 한 번에 뒤집을 수 있을 것. 설계 문서는 이걸 한마디로 줄여. 감지하되, 몰래는 절대 아니게.

"몰래는 아니게"를 이루는 조각은 하나하나 코드에 있어.

  • 먼저 보여줘. 엔진은 초안이 시작되기 전에 exchange.direction을 내보내. 번역된 단어보다 칩이 먼저 카드에 떠.
  • 두 사람 다 읽어. 칩은 브라우저의 Intl.DisplayNames로 언어마다 자기 글자로 이름을 써. Español → 한국어.
  • 누가 정했는지 남겨. 방향엔 Scribe, 글자, 아빠 중 누가 정했는지 적혀.
  • 되돌릴 수 있어. 탭 한 번이면 뒤집기 요청이 가고, 같은 말을 반대로 읽어.
  • 울타리가 있어. Spark 받아쓰기 바인딩은 계약상 언어 없는 호출을 이 레인 몫으로 남겨 뒀고, 답을 받는 라우트는 그런 호출을 보낼 수가 없어. 언어 코드가 들어오면 코드 모양인지도 확인해.

불변식 8: 피파 목소리는 아빠가 탭할 때만

원래 카드의 피파 목소리는 아빠가 탭해야 나와. 아빠는 누굴 부르기 전에 카드를 미리 준비하거든. 조용한 가게에서 저 혼자 울리는 목소리는 아무한테도 말하는 게 아니야. 실시간 대화에선 상대가 이미 앞에서 기다리고 있어. 그래서 아빠는 통역사에서만큼은 음소거 토글을 다는 조건으로, 아빠 말로 만든 카드가 바로 재생되게 했어.

울타리는 첫 번째 발췌인 킷의 speak 메서드에 보여. 아빠가 카드를 음소거했으면 바로 빠져나와. 교환의 화자가 아빠였고 카드에 녹음된 목소리가 있을 때만 틀어. 그러니까 아빠 눈으로 읽으라고 만든 릴레이는 소리 내서 읽힐 일이 없어. 그리고 피파가 말하는 동안 마이크는 귀를 막고, 말이 끝나고도 400 ms 더 막고 있어. 그 꼬리가 없으면 방에 울린 피파 목소리가 Scribe한테는 상대가 한 말로 들려서, 카드가 자기 말을 통역하기 시작해. 아빠의 음소거는 Clear를 눌러도 그대로야. 카드를 지운다고 소리가 다시 켜지진 않아.

원칙: 예외는 그걸 정당하게 만든 조건 안에 가두고, 조건을 바로 옆에 적어 둬. "상대가 이미 앞에 있다"는 한 레인의 자동 재생을 허락할 뿐이야. 다른 데선 아무것도 허락 안 해.

낯선 사람 목소리가 사는 곳

웨이터 녹음은 동의서 없이 담긴 낯선 사람 목소리야. 그게 정확히 어떻게 되는지 보자.

  • 사적으로 남겨. 카드는 발화마다 녹음을 같이 올려. Scribe가 한국어로 들었으면 recording-dad, 아니면 recording-other로. 그 녹음은 시팅의 미디어가 돼서, 아빠의 사적인 기록 안, 베일 씌운 로그 옆의 Spark 미디어 저장소에 들어가.
  • 문을 지나는 역할은 둘뿐. cwkPippa 녹음 라우트는 그 두 역할만 받아. 그래서 통역 녹음이 사진이나 첨부로 둔갑할 수 없어.
  • Spark 바깥에선 아무도 안 읽어. 설계 때 Beacon, Recall, 모바일 영상 파이프라인을 확인했고, 셋 다 Spark 미디어를 읽는 데가 없었어.
  • 목소리를 임베딩하지 않아. 목소리가 아빠 건지 확인하는 cwkPippa의 화자 귀는 통역 녹음을 한 번도 안 들어.
  • 소리는 딴 데를 거치지 않아. 소켓이 살아 있으면 소리는 기기에서 ElevenLabs로 바로 가. 보이스 모드랑 같고, 우리 쪽 중계는 하나도 안 거쳐.
  • 비공개 대화엔 Talk가 없어. 비공개 대화에서 연 카드엔 통역사가 아예 없어. 시팅은 Spark에 남고, 비공개 대화는 어디에도 아무것도 안 남기니까.

그러니까 낯선 사람 목소리는 남되, 한 군데에만 남아. 굳이 남기는 이유는 실용적이야. 말하는 도중에 폰 소켓이 끊기면 카드는 녹음을 계속하고, 연결이 돌아오면 엔진이 남은 파일을 받아써. 언어 이름은 배치 모델이 대. 이 경로에선 녹음이 먼저 올라가고 recording-other로 분류돼. Scribe가 듣기 전엔 누가 말했는지 아무도 모르니까.

보이스 모드가 아빠 목소리를 다른 사람 목소리와 어떻게 구별하게 됐는지, 그 화자 귀 이야기는 Voice Mode Quest의 Whose Voice 트랙이 가르쳐.

Code

cwkSpark, frontend/src/kit/kitInterpreter.ts (cwkSiblingKit에서 들여온 사본): 자동 재생 예외와 그 울타리 (원문 그대로, 505-517행)·typescript
  /** Pippa's voice plays at once on a card made from Dad's words (Dad's ruling
   * 2026-10-23), unless he muted it. The mic is deaf while she speaks. */
  private speak(cardId: string): void {
    if (this.state.muted || !cardId) return;
    const exchange = this.state.exchanges.find(item => item.refinedId === cardId);
    if (!exchange?.voice?.length || exchange.speaker !== 'dad') return;
    this.deafUntil = Number.POSITIVE_INFINITY;
    this.set({ playing: true });
    void this.transport.playVoice(exchange.voice).catch(() => {}).finally(() => {
      this.deafUntil = Date.now() + ECHO_TAIL_MS;
      this.set({ playing: false });
    });
  }
cwkPippa, backend/routes/interpret.py: 녹음은 두 역할로만 받아서 Spark로 넘긴다 (원문 그대로, 187-206행)·python
@router.put("/sessions/{moment_id}/recordings/{media_id}")
async def recording(
    moment_id: str,
    media_id: str,
    request: Request,
    role: str = Query(pattern="^(recording-dad|recording-other)$"),
    filename: str = Query(default="utterance.wav", max_length=200),
) -> dict[str, Any]:
    """The utterance's recording, kept with the sitting in Dad's Spark record
    only (a stranger's voice is never used for anything public)."""
    payload = await request.body()
    if not payload:
        raise HTTPException(status_code=400, detail="empty recording")
    return await _forward(
        "PUT",
        f"/api/moments/{_moment_id(moment_id)}/media/{_moment_id(media_id)}",
        params={"role": role, "filename": filename},
        content=payload,
        headers={"Content-Type": request.headers.get("content-type") or "audio/wav"},
    )

External links

Exercise

소프트웨어가 묻는 대신 감지하는 값 하나를 찾아. 언어, 시간대, 통화, 사용자 의도 같은 거. 그 값이 뭔가를 움직이기 전에 어디에 보이는지, 사람이 한 번에 어떻게 고치는지, 감지가 허락되는 범위가 정확히 어딘지 적어. 셋 중 하나라도 없으면 채워. 이어서 앱이 사용자 아닌 사람에 대해 저장하는 개인 데이터 하나를 골라서, 그걸 읽는 쪽을 전부 적어.
Hint
읽는 쪽 목록은 기능 말고 저장소에서 출발해. 테이블, 디렉터리, 미디어 역할을 grep으로 찾고 그걸 여는 경로를 전부 따라가. 못 찾았다고 읽는 쪽이 아닌 건 아니야. 그러니까 어느 레포를 언제 찾아봤는지 같이 적어 둬.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.