WebUI 메인 채팅이 첫 화면이었고, 트랙 1의 공용 훅이 같은 날 음성 모드를 모든 사이드킥 패널로 옮겼어. 글쓰기 편집기, 음악 엔진, 아트 작업실 등 전부 메인 채팅처럼 말해. 다들 같은 임베드로 같은 채팅 화면을 그리고 같은 훅을 붙이니까. 브라우저는 여전히 출처마다 한 번씩 마이크를 묻고, HTTPS나 localhost에서만 물어.
폰
네이티브 앱에 손이 제일 많이 갔고, 트랙 4에서 7까지의 일 대부분이 거기서 먼저 일어났어. 자기만의 스트리밍 디코더, 화면이 꺼져도 대화가 살아남게 하는 오디오 세션 주인 하나, 얼굴 화면, 대화를 따라가는 라이브 액티비티, 마이크 자신의 엔진에서 답을 틀어 주는 에코 캔슬. 변경은 전부 TestFlight로 아빠 폰에 나갔고, 이 퀘스트의 버그 중 여럿이 거기서 발견됐어.
폰에서도 같은 얼굴이 들어. 청록색 테두리, 같은 계량기, 일시정지, 그리고 끼어들기와 자막 토글.
맥: Firekeeper
가족의 받아쓰기 앱 Firekeeper가 맥에서 음성 모드로 들어가는 문이 됐어. 전역 단축키 하나로 어느 앱에서든 핸즈프리 대화를 위한 작은 떠 있는 패널이 열려. 메뉴에선 아무 소울과 대화를 시작하거나, 어느 화면에서 했든 최근 음성 대화를 이어 가거나, 메모리에만 사는 비밀 대화를 열 수 있어. 이 일엔 자기의 기기 안 음성 엔진을 안 써. cwkPippa의 실시간 세션으로 들어서, 중간 단어, 말 끝 창, 키텀이 웹과 폰의 것과 정확히 같아. 턴은 reply_modality: spoken을 달고 브레인 자신의 채팅 경로를 타는데, 메인 모델 자리가 아니라 음성 대화용으로 따로 잡아 둔 자리에서 돌아. 대화는 사이드바에 다른 대화처럼 나타나는 평범한 대화야. 트랙 7에서 말한 대로 목소리가 아니라 키로 끼어들고, 맥마다 마이크와 스피커를 따로 고를 수 있어. 원격 데스크톱 프로그램이 기본 장치를 자꾸 가져가 버렸거든.
손목
워치엔 앱이 자기 받아쓰기 화면 밖에서 돌릴 수 있는 음성 인식기가 없고, 가족 사설망에도 안 붙어 있어. 그래서 워치의 문은 폰을 거쳐. Pippa Voice 컴플리케이션이 손목에서 받아쓴 메시지를 받으면, 폰이 그걸 받아쓴 말 턴으로 만들고, 소울 목소리로 완성된 테이크를 받아서 MP3를 스트림이 아니라 파일로 워치에 넘겨. 아빠 워치에서 스피커로도, 이어폰으로도 증명했고, 크라운은 볼륨 손잡이야. 완전 핸즈프리 손목 루프도 있어. 워치가 녹음하고, 자기 소리 크기 규칙으로 말 끝을 정하고, 폰이 받아써. 그중 어느 부분이 실제 손목에서 증명됐고 어느 부분이 아닌지는 바로 옆에 적혀 있어.
대화를 건네주기
통역 앱 Spark는 문을 거절한 경우야. 웹 사이드킥은 다른 것처럼 말해. 그런데 폰 앱엔 대화 화면이 없고, 아빠는 거기에 음성 루프를 따로 키우지 말라고 정했어. 그래서 Spark의 'Pippa와 대화' 버튼은 그 순간의 대화를, 대화와 언어를 담은 링크로 폰 앱에서 열어. 폰 앱은 그걸 음성 모드로, 이미 듣는 상태로 열어.
안 만든 문
텔레그램 음성 메모와 여러 소울의 카운슬 턴은 문서에 설계가 다 있어. 아빠가 둘 다 미뤘고 안 할 수도 있다고 했으니, 그 설계는 계획이 아니라 기록으로 남아. 글로 적어 둔 '지금은 아니야'도 설계의 일부야.
Code
문마다 뒤에 있는 브레인 하나, 그리고 대화를 음성 모드로 폰에 건네는 링크·python
from dataclasses import dataclass
from urllib.parse import parse_qs, quote, unquote, urlparse
SCHEME = "cwkpippa"
LANGUAGES = {"ko", "en"}
def voice_link(conversation_id: str, language: str) -> str:
"""A sibling app hands a moment's thread to the phone app, in voice mode."""
if language not in LANGUAGES:
raise ValueError("KO or EN, picked by Dad, never auto")
return f"{SCHEME}://conversation/{quote(conversation_id, safe='')}?voice={language}"
@dataclass(frozen=True)
class OpenRequest:
conversation_id: str
listen_in: str | None # None: open the thread normally, no voice
def parse_link(url: str) -> OpenRequest:
parts = urlparse(url)
if parts.scheme != SCHEME or parts.netloc != "conversation":
raise ValueError(f"not a conversation link: {url}")
conversation_id = unquote(parts.path.lstrip("/")) # undo voice_link's quote
if not conversation_id:
raise ValueError("missing conversation id")
voice = parse_qs(parts.query).get("voice", [None])[0]
return OpenRequest(conversation_id, voice if voice in LANGUAGES else None)
# The doors: every one reaches the same brain with the same two facts.
DOORS = {
"WebUI main chat": {"loop": "useVoiceMode hook", "reply": "spoken"},
"Sidekick panels": {"loop": "the same hook, inside /embed/<app>", "reply": "spoken"},
"Phone app": {"loop": "native loop on the shared voice kit", "reply": "spoken"},
"Firekeeper (Mac)": {"loop": "native loop, key to cut in", "reply": "spoken"},
"Watch": {"loop": "records; the phone transcribes", "reply": "spoken"},
"Telegram, Council": {"loop": "deferred by Dad's ruling", "reply": None},
}
link = voice_link("conv-1234", "ko")
print(link, "->", parse_link(link))
print(parse_link("cwkpippa://conversation/conv-1234"))
odd = "private/쪽지 #2" # reserved characters round-trip
assert parse_link(voice_link(odd, "en")).conversation_id == odd
for door, how in DOORS.items():
print(f"{door:18s} {how['reply'] or '-':7s} {how['loop']}")