EJClaw 545a984ca5 feat(brain): voice bot answers in 존댓말 (polite Korean)
User requested the voice assistant speak politely. Update the persona to
require 존댓말 on every reply and rewrite the two 반말 output examples
(emotion demo + clarify-question) accordingly. Verified live: replies come
back polite and still one short sentence with emotion tags.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-30 00:58:22 +09:00

watch_sceen_ai

디스코드에서 음성으로 대화하는 AI. 최종 목표는 화면공유(Go Live)까지 실시간으로 보면서 대화하는 것이지만, 지금은 화면공유(눈)를 잠시 미뤄두고 음성 대화 루프 STT → 두뇌 → TTS부터 완성하는 단계다.

  • 실행 위치: 이 리눅스 호스트(.9, RTX 5050 8GB / ffmpeg / node v22 있음)
  • 현재 상태: 음성 루프 실엔진 동작 — GPU STT(faster-whisper), Claude 두뇌(Haiku), GPU TTS(MeloTTS, 감정 톤 반영)가 모두 붙었고 디스코드 봇과 왕복하는 voice-server (python -m wsai --voice-server)가 실서비스로 돈다. 상태 대시보드(:8787)에서 봇 정보· 서버/음성채널 선택·참여자·발화자·로그·프롬프트·화이트/블랙리스트를 실시간 제어한다(9장). 남은 것은 실제 사람 발화로 오디오 왕복을 눈으로 확인하는 최종 라이브 검증뿐.
  • 보류 중: 화면공유 비디오 수신(눈). 단, STT 입력은 디스코드 보이스로 유저 음성을 수신하므로 보이스 접속 자체는 지금도 쓴다(비디오만 미룸). 이 보이스 접속은 공식 Discord 봇(dave/bot.mjs, discord.js + @discordjs/voice)으로 하며 DAVE/MLS E2EE 합류·수신까지 라이브로 검증됐다(아래 8장). 셀프봇 경로는 폐기(비디오 트랙용만 보존).

1. 설계 원칙

  1. 지연이 전부다. 음성 대화형이라 "정확하지만 느린" 답은 실패다. 전 구간을 스트리밍으로 잇고, 목표는 사용자가 말 끝낸 뒤 첫 소리까지 1초 이내.
  2. 싼 것부터, 필요할 때만 비싸게. 로컬(GPU)에서 처리할 수 있는 건 로컬에서, 정말 필요한 것만 클라우드로 올린다.
  3. 부품은 교체 가능해야 한다. 귀/두뇌/입(그리고 나중의 눈)이 전부 wsai/interfaces.py의 Protocol이라, 어느 하나가 바뀌어도 나머지는 그대로 돌아간다. 실제로 눈(source/vision)은 선택 사항이라, 없으면 파이프라인이 음성 루프만 돌린다.

2. 전체 구조

두 개의 async 루프가 있고, 지금은 대화 루프만 켜서 쓴다. 인지(눈) 루프는 나중에 붙인다.

[대화 루프]  SpeechToText ──Utterance──▶  Brain(LLM) ──▶ TextToSpeech
             (귀)                          (두뇌)          (입)
                                             ▲
                                             │ (화면 맥락: 지금은 비어 있음)
[인지 루프]  FrameSource ─▶ VisionBackend ─┘   ← 보류 (source/vision = None)
             (눈)
  • 대화 루프: 사용자 발화 → 두뇌(대화이력 + 있으면 최신 화면 맥락) → 음성 응답.
  • 인지 루프: source/vision이 없으면 통째로 건너뛴다. 눈을 붙이면 최신 화면 맥락을 채워 넣어, 두뇌가 "지금 화면"을 참고할 수 있게 된다.
  • 두 루프는 비동기로 독립. 눈이 생겨도 대화 루프는 프레임 하나하나를 기다리지 않고 "가장 최근에 이해된 화면"만 읽는다.

데이터 타입/인터페이스는 wsai/interfaces.py에 정의돼 있다: Frame, ScreenObservation, Utterance, Reply / FrameSource, VisionBackend, SpeechToText, TextToSpeech, Brain, TextChannel.


3. 귀 / 두뇌 / 입 — 지금 만드는 음성 루프

귀 (STT) — 디스코드 보이스 수신

  • 로컬 마이크가 아니라 디스코드 보이스에서 유저가 말하는 음성을 수신한다. 공식 봇이 보이스 채널에 접속해(이미 통과한 DAVE/MLS E2EE 경로) 유저의 Opus 오디오를 받는다. @discordjs/voice의 VoiceReceiver가 DAVE 복호까지 처리해 유저별 Opus 스트림을 주고, 이를 Opus 디코드 → PCM으로 만들어 STT에 흘려보낸다.
  • faster-whisper로 실시간 부분 전사를 계속 돌리고, VAD로 발화 종료(endpointing)를 잡는다. 종료 판정 시점엔 전사가 사실상 끝나 있어, 판정 후 남는 건 VAD hangover + 짧은 마지막 디코드뿐이다. 목표 종료→텍스트 확정 ~150ms.
  • 디스코드 수신 특성상 지속 스트림 + 지터버퍼(수십 ms)가 붙는다 — 아래 지연 예산에 반영.

두뇌 (Brain)

  • Claude(OAuth, Haiku 계열)로 대화 응답 생성. 저비용·빠른 모델을 기본으로 한다.
  • 입력은 "대화이력 + (있으면) 최신 화면 맥락 1건"만 넣어 토큰을 작게 유지한다.
  • 눈이 없는 지금은 화면 맥락이 비어 있고, 두뇌는 그 사실을 솔직히 말한다.

입 (TTS) — 한국어 고정, 사람처럼

  • 한국어 전용으로, 최대한 자연스러운(사람 같은) 음성을 목표로 한다.
  • 지연을 줄이려고 첫 문장 전체를 기다리지 않고 첫 짧은 구절부터 청크 스트리밍으로 재생한다 (나머지는 재생되는 동안 이어서 합성). 목표 첫 소리 ~170ms.
  • 인터럽트: 사용자가 말하기 시작하면 재생 중인 TTS를 즉시 멈춘다(barge-in).
  • 트레이드오프: 가장 사람 같은 합성(예: XTTS 계열)은 느려서 1초 예산과 충돌한다. 그래서 빠른 한국어 엔진(MeloTTS, 이 호스트에 자산 있음)으로 첫 문장 지연부터 확보하고, 자연스러움이 부족하면 GPU + 문장 스트리밍으로 더 무거운 엔진을 검토한다(품질 vs 지연).

지연 예산 (목표 ≤ 1초)

유저가 디스코드 보이스에서 말을 멈춘 순간 → 첫 소리까지:

구간 목표
디스코드 보이스 수신 + Opus 디코드(지속 스트림·지터버퍼) ~80 ms
STT 종료 판정(VAD) + 마지막 청크 전사 ~150 ms
Brain 첫 토큰(Claude Haiku 스트리밍) ~350 ms
TTS 첫 문장 합성·재생 시작(한국어) ~170 ms
여유 ~100 ms
합계 ~0.85 s
  • Brain(머리)은 네트워크 TTFT라 우리가 못 줄인다 → 350ms로 고정. 대신 우리 GPU에서 도는 STT·TTS를 깎아 여유를 벌었다. 두 구간을 어떻게 줄이는지는 아래.
    • STT ~150ms: 발화 중 부분전사를 계속 돌려 종료 판정 시점엔 전사가 사실상 끝나 있게 한다. 남는 건 VAD hangover(~120ms) + 마지막 짧은 디코드(~30ms)뿐.
    • TTS ~170ms: 첫 문장 전체가 아니라 첫 짧은 구절만 먼저 합성해 청크 스트리밍으로 바로 재생을 시작한다(나머지는 재생되는 동안 이어서 합성).
  • Brain·TTS는 스트리밍이라, 응답 전체 완성이 아니라 "첫 소리"만 준비되면 재생된다.
  • 나중에 눈(화면 이해)을 붙여도, 화면 이해는 별도 인지 루프에서 미리 끝나 있어 응답 시점에 비전 지연이 끼어들지 않는다.

4. 눈 — 화면공유 수신 (보류, 나중에)

최종 목표엔 화면공유가 들어가지만, 지금은 의도적으로 미뤄둔 부분이다. 아래는 나중에 다시 붙일 때를 위한 기록이다. 두 트랙을 병행할 계획이었다.

트랙 A (주력) — 화면공유 스트림 직접 수신

스크린샷·브라우저 캡처가 아니라, 실제 화면공유 스트림 자체를 클라이언트처럼 수신한다. 공식 봇 API는 비디오 수신을 막으므로 유저 토큰(셀프봇) 으로 프로토콜 레벨에서 비디오 RTP를 받는다(전용 버너 계정만 사용). 파이프: join → Go Live "watch" 구독 → 암호화 RTP 수신 → DAVE(MLS E2EE) 복호 → 전송암호 복호 → VP8/H264 depayload → ffmpeg 디코드 → 프레임. DAVE는 @snazzah/davey(Rust NAPI), 수신은 discord.js-selfbot 계열.

솔직한 리스크: 완성형 라이브러리가 없어 직접 조립해야 하고, 셀프봇은 ToS 회색지대라 계정 밴 위험이 있다(→ 버너 전용). 그래서 음성 루프를 먼저 세우는 지금 순서가 안전하다.

트랙 B (폴백/개발용) — 로컬 렌더 화면 캡처

디스코드 클라이언트가 화면공유를 보고 있는 화면을 로컬에서 캡처한다. 트랙 A가 막힐 때의 안전판이자 개발용 실용 소스. poc/의 Xvfb 실험과 wsai/backends/capture_mss.py가 여기 해당.

두 트랙 모두 결과물은 동일한 Frame 스트림이라, 눈을 붙일 때 나머지는 손대지 않는다.


5. 실행

이 호스트에는 python 별칭이 없으므로 워크스페이스 venv를 쓴다(.venv/bin/python).

.venv/bin/python -m wsai --voice    # 눈 없는 음성 루프 데모 (STT→두뇌→TTS, 지금 초점)
.venv/bin/python -m wsai --dashboard # 상태 사이트 + 짧은 mock 샘플 후 대기(무한 생성 안 함)
.venv/bin/python -m wsai            # mock 데모 (눈 포함 전체 흐름, 몇 프레임 돌고 종료)
.venv/bin/python -m wsai --env      # WSAI_* 환경변수로 백엔드 조립
.venv/bin/python -m pip install pytest && .venv/bin/python -m pytest -q   # 스모크 테스트
  • WSAI_SOURCE=none WSAI_VISION=none 으로도 눈 없이(음성 루프만) 조립할 수 있다.
  • --dashboard는 기본으로 mock 발화 3개만 만든 뒤 대기한다. UI 시연용으로 계속 만들고 싶을 때만 --dashboard-loop-demo를 추가한다.
  • 백엔드별 추가 설치는 requirements.txt 주석 참고(faster-whisper, mss/pillow, anthropic 등).

6. 파일 / 디렉터리

경로 역할
wsai/interfaces.py 데이터 타입 + 컴포넌트 Protocol
wsai/pipeline.py 오케스트레이터(대화 루프 + 선택적 인지 루프)
wsai/state.py 최신 화면 맥락 공유 저장소(눈이 생기면 사용)
wsai/config.py / factory.py 설정 → 백엔드 조립. voice() = 눈 없는 음성 프리셋
wsai/backends/mock.py 무의존성 mock 전 계열
wsai/backends/capture_mss.py 로컬 화면 캡처(트랙 B, 보류)
wsai/backends/claude.py Claude 비전 + 두뇌
dave/bot.mjs 공식 봇 보이스 접속 + 유저별 Opus 수신(DAVE E2EE, 현재 경로)
dave/gate.mjs, dave/join.mjs 레거시 셀프봇 경로(폐기, 비디오 트랙 재개 시 참고용)
poc/ Xvfb+Chromium 캡처 실험(트랙 B 참고)
PLAN.md 단계별 착수 계획
tests/test_pipeline.py 파이프라인 스모크 테스트

7. 구현 순서 (검증 가능한 체크포인트)

음성 루프 먼저, 눈은 나중에.

  • 파이프라인 스켈레톤 — mock 모드로 전 흐름 동작.
  • 눈 없는 음성 루프. source/vision 없이 STT→Brain→TTS만 돌아간다(--voice). · 검증: python -m wsai --voice가 화면 없이 발화마다 응답을 낸다(테스트 포함).
  • V1 · 진짜 TTS(입). MeloTTS로 두뇌 응답을 실제 음성으로 합성. · 검증: 응답 텍스트가 .wav로 합성돼 들린다.
  • V2 · 진짜 STT(귀) — 엔진 완성. faster-whisper(상주 워커, WSAI_STT=whisper)로 wav를 한국어 텍스트로 전사. · 검증: 실제 왕복(MeloTTS wav → whisper)에서 문장이 거의 그대로 복원됨. warm 전사 ~1.2s(CPU, small/int8). · 남은 것: 디스코드 보이스 수신 오디오(audio_source)를 붙여 실시간 발화 스트림으로 연결(V4).
  • V3 · 진짜 두뇌. Claude OAuth(Haiku)로 대화 응답 생성. · 검증: 실제 발화에 자연스러운 답이 나온다.
  • V4 · 음성 왕복 + barge-in. 디스코드 보이스 수신→STT→Brain→TTS 스트리밍, 말 끊기, 지연 측정. · 검증: 디스코드에서 말로 묻고 말로 답을 듣는다, 첫 소리까지 1초 이내.
  • (나중) 눈 재개. 트랙 A/B로 화면공유 수신 → 화면 맥락을 두뇌에 연결.

8. 확정된 결정 (2026-08-11) + 배포 목표

  • GPU 사용 — OK. RTX 5050(.9)으로 STT/TTS를 돌린다. .9 로컬 도커는 NVIDIA CDI로 GPU 사용 가능.
  • 파이썬 버전 — STT/TTS는 3.11(faster-whisper·TTS 자산 호환), 오케스트레이터는 기존 코드 유지.
  • TTS 엔진 — 최대한 사람 같은 음성으로 하되, 전체 지연 1초 초과 금지가 상한. 후보(XTTS 등)의 첫 소리 지연을 GPU에서 실측해, 1초 예산에 맞는 가장 자연스러운 엔진을 채택한다(측정 기반 선택).
  • 두뇌 — Claude OAuth, 이 호스트에 연결된 것과 동일 크리덴셜 공유 (/home/claude/EJClaw/data/claude/.credentials.json). API키 아님.
  • STT 입력 경로 — 디스코드 보이스 수신(공식 봇). 유저 음성 Opus를 DAVE 복호·디코드해 STT로.

공식 봇 전환 (2026-08-16)

  • 보이스 접속을 셀프봇(유저 토큰)에서 공식 Discord 봇으로 마이그레이션. dave/bot.mjs (discord.js 14 + @discordjs/voice 0.19)가 봇 토큰으로 로그인 → 대상 채널 join → DAVE/MLS E2EE Ready → VoiceReceiver로 유저별 Opus 수신 → PCM 디코드까지 라이브 검증됨.
  • 봇은 이미 대상 서버(사지방)에 초대돼 있어 추가 초대 조치 불필요. 미초대 시 node dave/bot.mjs --invite로 초대 URL을 출력한다.
  • 셀프봇이 필요한 건 공식 봇이 막힌 화면공유 비디오 수신뿐이며, 그건 보류 상태다.

배포/테스트 목표

  • 대상: 디스코드 서버 1352269198297923648의 보이스 채널 1352269198914621465.
  • 봇 토큰: .env의 DISCORD_BOT_TOKEN(테스트봇, app id 1538122882528321536). ToS-safe.
  • .9 로컬 GPU 도커 이미지로 올려, 그 채널에 접속한 뒤 사람이 말하면 대화하도록 한다.
  • 첫 로딩 워밍업: 시작 시 모델 프리로드 + 더미 추론(CUDA 워밍) + 보이스 미리 접속 → 첫 대화도 지연 최소.

구현 마일스톤

  • M1 ✅ 공식 봇이 대상 보이스 채널에 상주 접속(DAVE 통과) + 발화자 감지 — dave/bot.mjs로 라이브 검증
  • M2 유저 음성 Opus 수신 → DAVE 복호 → PCM — @discordjs/voice VoiceReceiver가 대부분 처리(라이브 발화자로 최종 검증만 남음)
  • M3 faster-whisper STT(부분전사+VAD) → M4 Claude OAuth(Haiku) 두뇌
  • M5 한국어 TTS 첫 구절 청크를 보이스로 송신(DAVE 암호화) + barge-in
  • M6 통합 + 워밍업 + .9 GPU 도커 이미지화, 채널 라이브 테스트

9. 운영 음성 서버 + 대시보드 (구현됨)

디스코드 봇이 발화 wav를 POST /api/voice-turn으로 올리면, voice-server가 GPU STT → Claude 두뇌 → GPU TTS를 돌려 응답 wav를 돌려주고 봇이 채널에 재생한다. 같은 서버가 :8787에 상태 대시보드(단일 HTML, 외부 자산 없음)를 띄운다.

.venv/bin/python -m wsai --voice-server --host 0.0.0.0 --port 8787   # 실서비스(wsai-voice.service)

감정 TTS (대괄호 태그)

  • 두뇌가 답변에 [감정] 태그를 넣으면 그 태그는 읽지 않고 뒤 문장의 피치·속도를 바꿔 감정을 표현한다. 답변 중간에 감정이 바뀌면 그 지점부터 톤이 바뀐다. 예: [속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!
  • 감정 어휘는 Azure Neural TTS speaking styles + Ekman 기본감정을 한국어로 매핑 (wsai/backends/emotion.py). 감정 단어가 아닌 대괄호(예: [1번])는 내용을 그대로 읽는다.
  • 음성이 아닌 잡음으로 판단되면 답변을 [잡음]으로 두고 아무것도 재생하지 않는다.
  • 구현: 세그먼트별 합성 후 librosa 피치 시프트로 이어붙임(melo_worker.py), 시작 시 예열.

대시보드 기능

  • 봇 프롬프트 실시간 수정 — 상단 "📝 프롬프트" 팝업에서 현재 시스템 프롬프트를 보고 수정→저장하면 다음 답변부터 즉시 반영(빈칸 저장 시 기본값 복원). prompt_store 영속화.
  • 봇 제어 바 — 봇 정보/연결 상태, 참여 가능 서버 선택(상단 "없음"), 선택 서버의 음성채널 선택(상단 "없음"), 현재 음성채널 참여자(말하는 사람 🔊).
  • 화이트/블랙리스트 — 팝업에서 서버의 유저/역할을 검색해 추가/제거. 화이트리스트가 있으면 그 대상만 청취(비어있으면 전체), 블랙리스트는 제외. 봇이 발화자 SSRC→유저/역할로 필터 (dave/filter.mjs의 isAllowed).
  • 대화 카드 + 로그 검색 — 들음 / 생각(감정 톤 계획) / 답변 3분할, 발화자(🗣)·서버/채널(🔊) 표시, 단계별·총 소요시간. 대화 로그는 시간·유저(발화자)·서버·채널·내용으로 필터한다 (턴에 speaker/guild/channel 메타데이터를 실어 봇이 X-User/Guild/Channel-Name 헤더로 보고).
  • 이벤트 로그 패널 — 하단 고정 VSCode 터미널식, 열고닫기, 시작부터 기록. 텍스트/레벨 검색, 전체·라인별 삭제/수정(/api/logs/{clear,delete,edit}).

대시보드 ↔ 봇 제어 채널

봇은 아웃바운드 HTTP만 쓴다. 봇이 상태(정체성·서버·음성채널·참여자·역할·멤버)를 POST /api/bot/report로 올리면, 응답에 대시보드가 쌓아둔 명령(채널 join/leave)과 청취 필터가 실려 온다. 서버/채널 선택은 POST /api/bot/select, 필터는 POST /api/bot/lists로 저장한다(wsai/bot_control.py).

관련 파일

경로 역할
wsai/dashboard.py voice-turn 엔드포인트 + 상태 대시보드(HTML/JS) + 모든 API
wsai/monitor.py 턴/스텝/이벤트 텔레메트리 (thought·speaker·이벤트 id 포함)
wsai/backends/emotion.py [감정] 태그 파싱 + 감정→피치/속도 매핑
wsai/prompt_store.py 실시간 편집되는 시스템 프롬프트 영속화
wsai/bot_control.py 대시보드↔봇 제어 플레인(상태·명령·화이트/블랙리스트)
dave/filter.mjs 청취 화이트/블랙리스트 판정(isAllowed, 순수 함수)
Description
No description provided
Readme 2.1 MiB
Languages
Python 83.5%
JavaScript 15.2%
Dockerfile 0.7%
HTML 0.3%
Shell 0.3%