Records what's now built beyond the original plan: real GPU STT/brain/TTS via the voice-server, bracketed-emotion TTS (pitch/speed), and the dashboard's prompt editing, bot control bar, whitelist/blacklist, 3-row turns, and log dock, plus the dashboard<->bot control plane. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
267 lines
17 KiB
Markdown
267 lines
17 KiB
Markdown
# 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`).
|
|
|
|
```bash
|
|
.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. 구현 순서 (검증 가능한 체크포인트)
|
|
|
|
음성 루프 먼저, 눈은 나중에.
|
|
|
|
- [x] 파이프라인 스켈레톤 — mock 모드로 전 흐름 동작.
|
|
- [x] **눈 없는 음성 루프.** `source`/`vision` 없이 STT→Brain→TTS만 돌아간다(`--voice`).
|
|
· 검증: `python -m wsai --voice`가 화면 없이 발화마다 응답을 낸다(테스트 포함).
|
|
- [ ] **V1 · 진짜 TTS(입).** MeloTTS로 두뇌 응답을 실제 음성으로 합성.
|
|
· 검증: 응답 텍스트가 .wav로 합성돼 들린다.
|
|
- [x] **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, 외부 자산 없음)를 띄운다.
|
|
|
|
```bash
|
|
.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분할, 발화자(🗣) 표시, 단계별·총 소요시간.
|
|
- **로그 패널** — 하단 고정 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`, 순수 함수) |
|