xikhar/persona · 언어 TS 렌더러 + CJS Electron + ObjC++/C++ 네이티브 · 라이선스 MIT(에셋 제외) · 버전 0.1.0-beta.0 · 별 430 · 포크 39 · 생성 2026-07-28 · 파일 83개 · Node 24+)
Persona는 "음성 앱 위에 얹는 살아있는 아바타 스티커"다. Codex 데스크톱과 목소리로 대화하면, 화면 구석에 떠 있는 3D 캐릭터가 그 목소리의 크기에 맞춰 입을 뻐끔거리고 고개를 끄덕이며 반응한다. AI가 말하는 동안엔 아바타가 말하는 것처럼 보이고, 조용해지면 가만히 숨 쉬는 대기 자세로 돌아간다. 이게 전부지만, 이걸 마이크도 안 켜고·소리도 저장 안 하고·네트워크도 안 쓰고 해낸다는 게 핵심이다.
태엽 인형을 스피커 옆에 두면, 소리의 진동을 느껴 리듬에 맞춰 움직인다. Persona가 딱 그것이다. 스피커에서 나오는 음량만 느껴서 아바타를 움직인다 — 무슨 말인지 알아듣거나, 녹음하거나, 어딘가로 보내지 않는다.
차이는 "무엇을 안 하느냐"에 있다. 보통의 음성 캐릭터 앱은 마이크를 켜고 클라우드로 음성을 보낸다. Persona는 그 문을 아키텍처 레벨에서 아예 막아 두었다. 아바타를 그리는 화면 코드는 파일도, 프로세스도, 원본 오디오도 만질 수 없다.
Persona가 급부상한 배경엔 두 흐름의 교차가 있다. 하나는 음성으로 대화하는 코딩 에이전트(Codex 데스크톱 등)의 등장, 다른 하나는 MCP(Model Context Protocol)로 에이전트가 도구를 조종하는 생태계의 확산이다. Persona는 이 둘을 엮어 "에이전트가 조종하는, 목소리에 반응하는 데스크톱 마스코트"라는 신선한 조합을 내놓았다.
세일즈 포인트는 IRIS와 마찬가지로 "무엇을 안 했는가"다. 마이크 안 씀, 저장 안 함, 전송 안 함, 계정 없음. 그런데 이 절제가 마케팅 문구에 그치지 않고 코드 구조로 강제된다는 게 진짜 차별점이다.
| 항목 | Persona | 흔한 음성 아바타 앱 |
|---|---|---|
| 음성 입력 | 마이크 미사용 — 앱 출력만 엿들음 | 마이크 캡처 |
| 데이터 | 진폭만 계산 후 즉시 폐기 | 클라우드 전송·저장 흔함 |
| 플랫폼 | mac · Windows · Linux | 단일 OS가 많음 |
| 에이전트 제어 | MCP 서버 내장(애니메이션 지시) | 대개 없음 |
| 렌더러 권한 | fs·process·raw오디오 전부 차단 | 느슨한 경우 많음 |
맞다, 아바타를 띄우는 건 쉽다. 어려운 건 (1) 특정 앱의 소리만 골라 엿듣기, (2) 그러면서 프라이버시를 지키기, (3) 세 OS에서 똑같이 동작시키기다. Persona는 macOS Core Audio Process Tap, Windows WASAPI Process Loopback, Linux PipeWire라는 각 OS의 최신 시스템 오디오 API를 정공법으로 써서, "Codex가 내는 소리에만" 스코프를 건다. 유튜브·음악은 무시된다.
파티장에서 한 사람 목소리만 듣기. 방 안엔 여러 소리(여러 앱)가 섞여 있다. 아마추어는 방 전체 소음을 녹음한다(마이크). Persona는 특정 화자(Codex 프로세스)의 입에만 지향성 마이크를 대는 것과 같다 — OS의 프로세스별 오디오 탭으로, 그 앱의 출력 스트림에만 귀를 붙인다.
Persona는 세 층의 언어가 겹쳐 있다 — 화면을 그리는 TS 렌더러, 앱을 지휘하는 CJS Electron 메인, 소리를 엿듣는 네이티브 헬퍼(ObjC++/C++). 이 분리 자체가 보안 설계다.
OrbitControls(카메라 회전), Environment(HDRI 조명) 같은 기성 헬퍼.dawn.exr).main.cjs가 창·트레이·글로벌 단축키·URL 프로토콜·라이프사이클을 총괄.McpServer + StreamableHTTPServerTransport). 에이전트가 아바타를 조종하는 통로.zod/v4).http로 로컬 브리지 서버(포트 47831) 구현.PersonaAudioListener.mm — Objective-C++, Core Audio Process Tap(CATapDescription).PersonaAudioListener.cpp — C++, WASAPI Process Loopback(WRL COM), Mmdevapi.lib/Ole32.lib 링크.pw-dump/pw-record CLI를 spawn.scripts/*.cjs — build-native, check-assets(라이선스 게이트), write-checksums, check-release-tag.공식 문서(docs/DEVELOPMENT.md)는 Persona를 "의도적으로 좁게 만든 네 개의 계층"으로 설명한다. 각 계층은 바로 아래 계층에게 정규화된 이벤트만 넘기고, 그 이상의 권한을 주지 않는다. 이 "좁음"이 곧 보안이다.
가장 저수준이자 OS별로 갈리는 부분. 놀라운 점은 세 OS가 완전히 다른 API를 쓰면서도 똑같은 정규화 공식을 공유한다는 것이다: (rms − 0.0025) × 7.5를 0~1로 clamp. 노이즈 플로어 0.0025, 게인 7.5가 세 구현(JS normalizeRms, macOS meterIOProc, Windows packetSquareSum)에 동일하게 박혀 있다. 이식성의 교과서적 예다.
"Codex 소리만" 골라내는 프로세스 트리 타겟팅도 정교하다. Codex가 자식 프로세스로 오디오를 낼 수 있어서, macOS/Windows는 부모→자식 트리를 따라가고(Windows는 PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE 플래그), Linux는 /proc/<pid>의 cmdline/comm/exe를 최대 10단계 거슬러 올라가 CODEX_IDENTITY 정규식으로 매칭한다. PERSONA_TARGET_PROCESS_PATTERN 환경변수를 바꾸면 다른 음성 앱도 타겟이 된다.
창·트레이·단축키를 관리하는 오케스트레이터. 여기서 AudioActivityGate(electron/audio-activity-gate.cjs)가 핵심 상태머신을 돌린다. 레벨이 0.018을 넘으면 즉시 speaking으로 바꾸되, 임계 이하로 떨어져도 900ms 동안(DEFAULT_SPEECH_RELEASE_MS) speaking을 유지한다.
말하다 숨 쉬는 순간마다 자세가 튀면 이상하다. 사람이 "안녕하세요 ...(0.3초 숨)... 반갑습니다"라고 할 때, 그 짧은 공백마다 아바타가 대기 자세로 돌아갔다 다시 말하기 자세로 튀면 로봇 같다. 그래서 입은 음량을 즉시 따라가되, 몸은 900ms 지연을 둔다 — 문장 사이 공백을 자연스럽게 이어 붙이는 것이다.
Persona 보안의 심장. 렌더러(화면 코드)는 파일시스템도, 프로세스도, 원본 오디오도 만질 수 없다. preload가 contextBridge로 오직 personaBridge.getSnapshot / subscribe / hide 세 가지 정규화된 함수만 노출하기 때문이다. 렌더러는 "지금 speaking이고 레벨은 0.4"라는 결론만 받을 뿐, 그 결론이 어떤 오디오에서 나왔는지 원본에 접근할 방법이 없다.
contextIsolation:true, nodeIntegration:false, sandbox:true 세 스위치를 켜고, preload로 딱 필요한 API만 화이트리스트한다. 여기에 setWindowOpenHandler(deny), will-navigate 화이트리스트(navigation-policy.cjs), 엄격한 CSP까지. "권한은 기본적으로 없고, 필요한 것만 명시적으로 연다"는 원칙의 실전판.
이 프로젝트에서 가장 재미있는 부분. 원본 오디오 파형이 없는데 어떻게 입모양을 만들까? 실제 음소(phoneme) 분석이 아니라, 진폭 기반 가짜 비세임(viseme) 순환이다(src/hooks/useAmplitudeLipSync.ts).
# useAmplitudeLipSync의 아이디어 (의사코드)
visemes = ['aa','ee','ih','oh','ou'] # VRM 표정 5개
phase = (phase + dt * speed) % len # 시간에 따라 순환
mouth = visemes[floor(phase)]
# smoothed: 상승 0.055s / 하강 0.1s 비대칭 지수 스무딩
weight = smooth(amplitude, up=0.055, down=0.1)
setExpression(mouth, weight) # 진폭이 크면 입 크게
즉 소리가 클수록 입을 크게 벌리고, 어떤 입모양을 쓸지는 시간에 따라 5개를 그럴듯하게 순환시킨다. 실제 발음과 일치하진 않지만, 사람 눈엔 충분히 "말하는 것처럼" 보인다. 상승은 빠르게(0.055s) 하강은 느리게(0.1s) 하는 비대칭 스무딩이 자연스러움의 비결이다.
최신 커밋(#2)의 핵심. 아바타는 두 곳에서 명령을 받는다 — 음성 구동 몸동작(말하면 TALK)과 MCP 요청 애니메이션(에이전트가 "춤춰!"). 둘이 부딪히면? src/animation-priority.ts의 resolveBodyAnimation이 정리한다. MCP 애니메이션은 requestId로 override되어 한 번 재생(once) 후 자동으로 음성 상태로 복귀한다. useVrmAnimation.ts는 requestGeneration 카운터로 경쟁 상태(race condition)를 막고, crossFadeAnimationActions로 부드럽게 블렌딩한다(TALK→IDLE 1.15s, →TALK 0.85s, 기타 0.7s).
IDLE / GREETING / TALK / HAPPY / FINGER_GUN / DANCE는 파일 경로가 아니라 제품 계약명이다(src/animation-catalog.ts). TALK는 talk1/2/3 중 nextAnimation으로 라운드로빈된다. 이렇게 이름으로 추상화해 두면, 캐릭터 팩을 통째로 바꿔도 MCP 설정이나 파일시스템 접근 없이 새 모션으로 교체된다.
codex mcp add persona --url http://127.0.0.1:47831/mcp로 등록하면 "애니메이션 재생 / 창 표시·숨김 / 상태 조회" 툴을 에이전트에게 제공한다. 즉 에이전트가 아바타의 리모컨을 쥐는 셈.electron/bridge-server.cjs는 로컬 서버지만 방어가 촘촘하다: Host 헤더가 loopback이 아니면 403, Origin은 정규식 TRUSTED_ORIGIN(127.0.0.1/localhost/codex-app://)만 허용, 바디 64KB 제한, MCP는 요청마다 stateless transport를 새로 생성(세션·서버푸시 없음). "로컬이니까 괜찮겠지"를 경계하는 좋은 습관.
구조에서 읽히는 의도는 "권한별 격리"다. 위험한 것(오디오 캡처)은 native/에 가두고, 그 결과만 electron/이 받아 정규화하고, 화면(src/)은 결론만 본다. 그리고 거의 모든 모듈에 짝지어진 테스트 파일이 있다 — node --test가 CJS를, Vitest가 렌더러를 검증한다.
캐릭터 모델(.vrm/.vrma)은 리포에 커밋되지 않는다(테스트 전용이고 별도 라이선스). 애플리케이션 소스는 MIT지만 번들 에셋은 제외되며, 배포 워크플로는 distributionAllowed:true + 라이선스 메타데이터가 완성되기 전엔 빌드를 막는다(fail-closed). 그래서 GitHub API가 라이선스를 "Other/NOASSERTION"으로 잡는다. 직접 돌리려면 본인 VRM 아바타를 넣어야 한다.
특정 앱의 소리만 캡처하는 API는 흔히 다루지 않는 저수준 영역이다. macOS Core Audio Process Tap(AudioHardwareCreateProcessTap, CATapDescription), Windows WASAPI Process Loopback(ActivateAudioInterfaceAsync + AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK), Linux PipeWire(pw-dump 그래프 조회 / pw-record 캡처) — 셋을 한 프로젝트에서 비교하며 배울 기회는 드물다.
많은 Electron 앱이 편의를 위해 nodeIntegration을 켠다. Persona는 정반대로, contextIsolation·sandbox·preload 최소 노출·CSP·네비게이션 화이트리스트까지 "보안 기본형"을 전부 적용한 살아있는 예제다. preload.cjs와 navigation-policy.cjs가 교과서.
@modelcontextprotocol/sdk의 McpServer.registerTool + zod 스키마 + annotations(readOnlyHint 등) + Streamable HTTP stateless 패턴. "내 앱을 에이전트가 조종하게 만드는" 최소 구현을 mcp-server.cjs 한 파일에서 볼 수 있다.
three-vrm의 expressionManager(비세임·blink), AnimationMixer 크로스페이드, VRMUtils 최적화(combineSkeletons, combineMorphs, rotateVRM0). "3D 캐릭터를 웹 기술로 실시간 구동"하는 실전 코드다.
세 OS를 하나의 리스너 계약(onLevel/onActivity/onSession/onStatus)으로 통일하고, RMS 정규화 공식을 공유하는 법. "구현은 달라도 인터페이스는 하나"라는 원칙의 모범.
aa/ih/ou 등으로 정의돼 있어, 이걸 순환시키면 말하는 입모양이 된다. Persona는 소리를 분석하지 않고 진폭+시간으로 비세임을 가짜로 순환시킨다.| 항목 | 요구 / 권장 |
|---|---|
| 런타임 | Node.js 24+, npm, 하드웨어 가속 그래픽 세션 |
| macOS | 14.2+ · System Audio Recording 권한 1회 승인 · Xcode CLT · arm64/x64 DMG+ZIP |
| Windows | build 20348+(프로세스 루프백) · VS Build Tools C++ 워크로드(빌드 시) · NSIS |
| Linux | pw-dump/pw-record가 PATH에(PipeWire) · AppImage/DEB |
| 캐릭터 에셋 | 본인 VRM/VRMA 필요 — 리포에 미포함 |
| 에이전트 연동(선택) | Codex 등 MCP 클라이언트 — codex mcp add persona --url .../mcp |
Persona는 0.1.0-beta.0, 2026-07-28 생성된 초기 프로젝트다. CHANGELOG도 "active development" 상태. macOS는 시스템 오디오 녹음 권한을 요구하는데(민감 권한), 이 권한은 원리상 앱이 시스템 소리를 들을 수 있음을 뜻한다 — Persona는 그걸 진폭 계산에만 쓰고 즉시 버린다고 코드로 보장하지만, 권한 부여 전 소스를 한 번 확인하는 습관이 좋다.
앱을 띄운 뒤 xdg-open 'persona://speaking?level=0.5'(mac/win은 해당 URL 열기)로 아바타가 입을 벌리는지 관찰하자. 트레이 메뉴의 "Preview dance"로 MCP 없이 애니메이션을 미리 볼 수도 있다. protocol-actions.cjs가 이 URL을 어떻게 파싱하는지 읽어 보라.
curl로 http://127.0.0.1:47831/events에 state/audio-level/animation JSON을 POST해서(INTEGRATIONS.md 예시 참고), 아바타를 코드 없이 움직여 보자. Origin/Host 검사에 걸려 403이 나는 것도 일부러 재현해 보안 로직을 체감하라.
useAmplitudeLipSync.ts의 순환 속도·상승/하강 스무딩(0.055s/0.1s)을 바꿔 가며, 입모양이 소리에 얼마나 붙거나 늘어지는지 관찰하자. "더 수다스러운" 느낌과 "차분한" 느낌을 각각 만들어 보라.
animation-catalog.ts에 새 타입(예: THINK)을 추가하고, mcp-server.cjs의 ANIMATION_EVENT_NAMES와 protocol-actions.cjs에 연결해 에이전트가 부를 수 있게 하자. 대응 테스트도 추가해 계약이 깨지지 않게 하라.
PERSONA_TARGET_PROCESS_PATTERN 정규식을 바꿔 Codex 대신 다른 음성 앱(예: 특정 미디어 플레이어·회의 앱)의 출력에 아바타를 반응시켜 보자. process-discovery.cjs와 프로세스 트리 매칭 로직을 추적하며 왜 그 앱만 잡히는지 이해하라.
| 주차 | 주제 | 무엇을 / 왜 |
|---|---|---|
| 1주차 | 디지털 오디오 기초 | PCM·샘플레이트·RMS 진폭·노이즈 플로어. "소리가 사실은 숫자 배열"이라는 관점 잡기. |
| 2주차 | 시스템 오디오 캡처 | macOS Core Audio Tap / Windows WASAPI Loopback / Linux PipeWire. 프로세스별 오디오 스코프의 원리. |
| 3주차 | Electron 아키텍처·보안 | 메인/렌더러/preload 3분할, contextIsolation·sandbox·IPC. Persona의 4계층 재현. |
| 4주차 | WebGL / Three.js | 씬·카메라·조명·머티리얼, react-three-fiber 선언적 렌더링. Scene.tsx/Avatar.tsx 분석. |
| 5주차 | VRM 아바타·애니메이션 | three-vrm expressionManager, AnimationMixer 크로스페이드, VRMA 클립. 립싱크·blink 구현. |
| 6주차 | MCP 서버 구축 | @modelcontextprotocol/sdk, zod 스키마, Streamable HTTP stateless. 내 앱을 에이전트 도구로. |
| 7주차 | 크로스플랫폼 배포 | electron-builder, 코드사이닝·entitlements, 체크섬·릴리스 게이트. fail-closed 파이프라인. |
| 8주차 | 종합 프로젝트 | 나만의 "소리에 반응하는 데스크톱 위젯"을 처음부터 빌드해 3 OS로 배포까지. |
docs/DEVELOPMENT.md · INTEGRATIONS.md · RELEASING.md — 이 딥다이브의 1차 소스