Compare commits
46 Commits
8b9a78ae14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1f059a018 | ||
|
|
5d9161982f | ||
|
|
be4bc87edf | ||
|
|
50838ef602 | ||
|
|
441ab4831f | ||
|
|
7eb590b729 | ||
|
|
d3cf4e01b5 | ||
|
|
6a2865d899 | ||
|
|
df07224feb | ||
|
|
1cb7658290 | ||
|
|
87b77f9997 | ||
|
|
4db73bf69f | ||
|
|
f585ed7b76 | ||
|
|
2c4c9ad82c | ||
|
|
356d1128fa | ||
|
|
ee6f6b7f55 | ||
|
|
575ac2949a | ||
|
|
95e2d4472b | ||
|
|
0f94245d5f | ||
|
|
b9c929a73f | ||
|
|
77d7cd8b56 | ||
|
|
51811ad251 | ||
|
|
63fcfb7ba2 | ||
|
|
6a138eff3a | ||
|
|
4898192ae1 | ||
|
|
9bac6d170a | ||
|
|
6b0755e1ff | ||
|
|
3d76cd6c52 | ||
|
|
27e449f9f1 | ||
|
|
5327f8ec7c | ||
|
|
3cc262ed18 | ||
|
|
cfad568029 | ||
|
|
12f7399b36 | ||
|
|
9f3a57d8a0 | ||
|
|
c93691cc26 | ||
|
|
1eb6620eb3 | ||
|
|
b0ab23909e | ||
|
|
2b6059141e | ||
|
|
b0780988a2 | ||
|
|
932e1b76b2 | ||
|
|
d756ea4cf5 | ||
|
|
375e1e5539 | ||
|
|
07e773a5ac | ||
|
|
7d20a40a84 | ||
|
|
4eeddc4b1f | ||
|
|
0c90856282 |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
dave/node_modules/
|
||||
poc/frames/
|
||||
.git
|
||||
.env
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
poc/frames/
|
||||
.env
|
||||
node_modules/
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# watch_sceen_ai — GPU-ready test image for the .9 host (RTX 5050).
|
||||
#
|
||||
# Bundles everything the current M1 milestone needs:
|
||||
# * CUDA runtime (so future faster-whisper / TTS can use the GPU)
|
||||
# * Python 3.11 (STT/TTS asset compat per README) for the wsai voice pipeline
|
||||
# * Node 22 + ffmpeg for the dave/ official-bot voice joiner (DAVE/MLS E2EE)
|
||||
#
|
||||
# STT/TTS/Brain are still mock at this milestone, so nothing GPU-heavy runs yet;
|
||||
# the image is built GPU-capable so the next milestones drop straight in.
|
||||
FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
# --- system deps: python 3.11, node 22, ffmpeg -----------------------------
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
software-properties-common ca-certificates curl gnupg ffmpeg \
|
||||
&& add-apt-repository -y ppa:deadsnakes/ppa \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3.11 python3.11-venv python3.11-dev \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& ln -sf /usr/bin/python3.11 /usr/local/bin/python \
|
||||
&& python -m ensurepip --upgrade \
|
||||
&& python -m pip install --no-cache-dir --upgrade pip pytest \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# --- dave/ node deps (cached layer) ----------------------------------------
|
||||
COPY dave/package.json dave/package-lock.json ./dave/
|
||||
RUN cd dave && npm ci
|
||||
|
||||
# --- app source ------------------------------------------------------------
|
||||
COPY wsai ./wsai
|
||||
COPY tests ./tests
|
||||
COPY dave ./dave
|
||||
COPY requirements.txt PLAN.md README.md ./
|
||||
COPY docker-entrypoint.sh /usr/local/bin/entrypoint
|
||||
RUN chmod +x /usr/local/bin/entrypoint
|
||||
|
||||
# .env (DISCORD_BOT_TOKEN) is NOT baked in — mount it at runtime:
|
||||
# -v $(pwd)/.env:/app/.env:ro
|
||||
ENTRYPOINT ["entrypoint"]
|
||||
CMD ["smoke"]
|
||||
84
PLAN.md
Normal file
84
PLAN.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# watch_sceen_ai — 착수 계획 (확정본)
|
||||
|
||||
디스코드 화면공유를 실시간으로 함께 보며 **음성으로** 대화하는 AI.
|
||||
|
||||
## 방향 전환 (2026-08-11)
|
||||
|
||||
- **화면공유(눈) 부분은 일단 보류**하고, 먼저 **음성 대화 루프 STT → 두뇌 → TTS**를 완성한다.
|
||||
- 파이프라인은 이제 눈 없이(source/vision = None) 돌아간다: `python -m wsai --voice`.
|
||||
- 화면공유 관문 1(DAVE) 성과는 아래에 보존. 나중에 눈을 다시 붙일 때 재사용한다.
|
||||
|
||||
## 공식 봇 전환 (2026-08-16)
|
||||
|
||||
- 보이스 접속(=STT 입력 경로)을 **셀프봇(유저 토큰) → 공식 Discord 봇**으로 마이그레이션 완료.
|
||||
`dave/bot.mjs`(discord.js 14 + @discordjs/voice 0.19)가 봇 토큰으로 대상 채널 join →
|
||||
DAVE/MLS E2EE Ready → 유저별 Opus 수신까지 라이브 검증됨. ToS-safe.
|
||||
- 셀프봇은 공식 봇이 막는 **화면공유 비디오 수신 트랙**용으로만 남기고, 그건 보류 상태다.
|
||||
아래 1단계(눈) 셀프봇 서술은 그 보류된 비디오 트랙 기준이다.
|
||||
|
||||
## 확정된 결정 (2026-08-09)
|
||||
|
||||
1. **캡처 방식 (눈)** — 사용자 요구: 스크린샷/브라우저 캡처 ❌,
|
||||
**실제 실시간 화면공유 스트림 자체를 진짜 클라이언트처럼 수신**해야 함.
|
||||
→ **셀프봇(유저 토큰)으로 프로토콜 레벨에서 비디오 RTP 스트림을 수신·디코딩**한다.
|
||||
- 근거(원문 확인): Discord 공식 봇은 비디오를 완전 차단 → 봇으로는 불가. 반드시 유저 토큰(셀프봇) 필요.
|
||||
- `discord.js-selfbot-v13`(werift-rtp 사용)이 수신 노출: `VoiceReceiver`(수신 스트림 레코딩), `StreamConnection`(스크린셰어), `receiverData` 이벤트(ssrcData: userId, **hasVideo**, RtpPacket). → 남의 화면공유 수신 기술적으로 가능.
|
||||
- 수신 파이프: 셀프봇 join + Go Live "watch stream" 시그널링으로 비디오 SSRC 구독 → 암호화 RTP 비디오 수신 → 복호화(xsalsa20_poly1305/libsodium) → depayload(VP8/H264) → ffmpeg 디코드 → 프레임.
|
||||
- 위험/제약: (a) 유저 토큰 = **Discord ToS 위반, 계정 밴 위험** → 반드시 버리는 계정 사용. (b) 완성형 "수신→프레임" 라이브러리는 없음, 조립 필요. (c) 주력 셀프봇 라이브러리는 2025-10 아카이브(read-only) → 프로토콜 변경 시 깨질 수 있음.
|
||||
- ❌ 폐기: Xvfb + 브라우저 캡처(스크린샷) 방식 — 사용자가 명시적으로 거부. `poc/`는 참고용 폴백으로만 남김.
|
||||
2. **대화 방식** — 음성 실시간. STT→LLM→TTS 스트리밍으로 지연 최소화, 사용자 인터럽트 지원.
|
||||
3. **두뇌** — Claude **OAuth**로 가장 싸고 빠른 모델(Haiku 계열).
|
||||
+ .9의 RTX 5050으로 소형 로컬 VLM을 돌려 매 프레임 1차 이해·**화면 변화 감지**를 값싸게 처리,
|
||||
어려운 화면만 Haiku로 에스컬레이션 (하이브리드).
|
||||
4. **실행 위치** — 이 리눅스 호스트(.9).
|
||||
|
||||
## 단계별 착수 목록
|
||||
|
||||
### 1단계 · 눈: 화면공유 스트림 수신 ← 지금
|
||||
- [x] 호스트 도구 확인 (Node 22/ffmpeg/libsodium 가능)
|
||||
- [x] 수신 방법 조사 확정: 셀프봇 프로토콜 레벨 비디오 RTP 수신
|
||||
- [x] **버리는 디스코드 유저 계정 + 토큰 확보, ToS/밴 위험 수용 확인** (`.env`, burner tkrmagid_bot)
|
||||
- [x] **[관문 1 통과] DAVE/MLS(E2EE) 게이트웨이 4017 검증** — 아래 참조
|
||||
- [ ] 셀프봇으로 음성채널 join → 상대 Go Live 스트림 watch/구독 (비디오 SSRC 획득)
|
||||
- [ ] 들어오는 비디오 RTP 수신 → **DAVE 복호화(daveSession.decrypt)** → depayload(VP8/H264)
|
||||
- [ ] ffmpeg로 디코드 → 프레임(JPEG/PNG) 추출 PoC (진짜 공유화면 수신 검증)
|
||||
- [ ] 프레임 변화 감지(동일 화면 반복 전송 방지) + 소스 인터페이스
|
||||
|
||||
#### 관문 1 (arbiter 지정 fail-fast) — 통과 확정 2026-08-09
|
||||
DAVE(MLS) E2EE가 전면 강제라 "토큰만 꽂으면 됨"이 깨졌다는 게 핵심 리스크였음.
|
||||
`dave/gate.mjs` 로 실측 검증 → **통과**:
|
||||
- 셀프봇 유저토큰으로 메인 GW IDENTIFY → 음성채널 join → 보이스 GW v8 IDENTIFY(`max_dave_protocol_version=1`).
|
||||
- 결과: **close 4017 없음**. `SESSION_DESCRIPTION`에서 `dave_protocol_version=1` 협상됨(= 해당 채널 E2EE 활성).
|
||||
- `@snazzah/davey`(Rust NAPI, DAVE 구현)로 MLS 멤버십 핸드셰이크 완주:
|
||||
op25 external_sender 수신 → op26 key_package 송신 → op27 proposals 처리(commit+welcome 생성)
|
||||
→ op28 commit_welcome 송신 → op29 announce_commit → `processCommit` → **MLS session ready=true**.
|
||||
- 5초간 서버 disconnect 없이 멤버십 유지, voicePrivacyCode 산출됨(실제 E2EE 그룹 참여 증명).
|
||||
- 결론: 셀프봇이 DAVE E2EE 보이스 그룹에 **정식 멤버로 합류 가능**. 옵션 A(직접 수신) 실현 가능 확정.
|
||||
- 바이너리 프레이밍 확정: 수신 `[u16 seq][u8 op][payload]`; op29/30 payload는 `[u16 transition_id][blob]`;
|
||||
op27 payload는 `[u8 op_type][proposals]`; 송신 op26/28은 `[u8 op][blob(+welcome)]`.
|
||||
processProposals에는 채널 내 인식 유저ID(op11 clients_connect)를 넘겨야 함(안 넘기면 UnexpectedUser).
|
||||
|
||||
#### 다음 (관문 통과 후 A 강행)
|
||||
- 상대의 Go Live 스트림 구독: op **video** 스트림 SSRC 확보(스트림 시청 시그널링) — Go Live 중인 소스 필요.
|
||||
- UDP로 들어오는 SRTP 비디오 패킷 수신 → RTP 헤더 파싱 → `daveSession.decrypt(userId, VIDEO, packet)` E2EE 복호화 →
|
||||
전송암호(aead_aes256_gcm_rtpsize) 복호 → VP8/H264 depayload → ffmpeg 디코드 → 프레임.
|
||||
|
||||
### 3단계 · 두뇌 (하이브리드 비전)
|
||||
- [ ] 로컬 VLM(예: moondream2 / Qwen2-VL-2B) 프레임 1차 이해 + 변화 감지
|
||||
- [ ] Claude OAuth(Haiku)로 어려운 프레임 에스컬레이션
|
||||
- [ ] 최신 화면 맥락 저장소
|
||||
|
||||
### 4단계 · 음성 대화
|
||||
- [ ] STT (faster-whisper, 스트리밍/부분전사)
|
||||
- [ ] 대화 브레인 (화면맥락 + 대화이력)
|
||||
- [ ] TTS (MeloTTS 등, 이 호스트에 자산 있음)
|
||||
- [ ] 말 끊기 인터럽트 + 지연 최적화
|
||||
|
||||
### 5단계 · 통합·운영
|
||||
- [ ] 인지 루프 + 대화 루프 동시 실행
|
||||
- [ ] proactive(화면 크게 바뀌면 먼저 말 걸기)
|
||||
- [ ] 끊김 복구, 비용 모니터링, 서비스화
|
||||
|
||||
## 주의
|
||||
- 캡처용 디스코드 계정은 메인 말고 **별도 계정 권장**(자동화 회색지대, 밴 위험 최소화).
|
||||
- 로컬 GPU 8GB VRAM 제약 → 소형 VLM만. 큰 이해는 클라우드.
|
||||
97
README.md
97
README.md
@@ -5,11 +5,15 @@
|
||||
STT → 두뇌 → TTS부터 완성**하는 단계다.
|
||||
|
||||
- 실행 위치: 이 리눅스 호스트(.9, RTX 5050 8GB / ffmpeg / node v22 있음)
|
||||
- 현재 상태: 음성 루프 뼈대 동작 — 파이프라인이 **눈 없이(화면공유 없이)** 돌아간다
|
||||
(`python -m wsai --voice`). STT/TTS/두뇌는 아직 mock이며, 실제 엔진 연결이 다음 목표.
|
||||
- 현재 상태: 음성 루프 **실엔진 동작** — GPU STT(faster-whisper), Claude 두뇌(Haiku),
|
||||
GPU TTS(MeloTTS, 감정 톤 반영)가 모두 붙었고 디스코드 봇과 왕복하는 **voice-server**
|
||||
(`python -m wsai --voice-server`)가 실서비스로 돈다. 상태 대시보드(:8787)에서 봇 정보·
|
||||
서버/음성채널 선택·참여자·발화자·로그·프롬프트·화이트/블랙리스트를 실시간 제어한다(9장).
|
||||
남은 것은 실제 사람 발화로 오디오 왕복을 눈으로 확인하는 최종 라이브 검증뿐.
|
||||
- 보류 중: 화면공유 **비디오** 수신(눈). 단, STT 입력은 **디스코드 보이스로 유저 음성을
|
||||
수신**하므로 셀프봇의 보이스 접속 자체는 지금도 쓴다(비디오만 미룸). E2EE 합류 관문은
|
||||
이미 통과해 두었다(아래 8장).
|
||||
수신**하므로 보이스 접속 자체는 지금도 쓴다(비디오만 미룸). 이 보이스 접속은
|
||||
**공식 Discord 봇**(`dave/bot.mjs`, discord.js + @discordjs/voice)으로 하며 DAVE/MLS
|
||||
E2EE 합류·수신까지 라이브로 검증됐다(아래 8장). 셀프봇 경로는 폐기(비디오 트랙용만 보존).
|
||||
|
||||
---
|
||||
|
||||
@@ -53,9 +57,10 @@ STT → 두뇌 → TTS부터 완성**하는 단계다.
|
||||
## 3. 귀 / 두뇌 / 입 — 지금 만드는 음성 루프
|
||||
|
||||
### 귀 (STT) — 디스코드 보이스 수신
|
||||
- 로컬 마이크가 아니라 **디스코드 보이스에서 유저가 말하는 음성을 수신**한다. 셀프봇이
|
||||
보이스 채널에 접속해(이미 통과한 DAVE/MLS E2EE 경로) 유저의 Opus 오디오 RTP를 받고,
|
||||
복호 → Opus 디코드 → PCM으로 만들어 STT에 흘려보낸다.
|
||||
- 로컬 마이크가 아니라 **디스코드 보이스에서 유저가 말하는 음성을 수신**한다. **공식 봇**이
|
||||
보이스 채널에 접속해(이미 통과한 DAVE/MLS E2EE 경로) 유저의 Opus 오디오를 받는다.
|
||||
@discordjs/voice의 `VoiceReceiver`가 DAVE 복호까지 처리해 유저별 Opus 스트림을 주고,
|
||||
이를 Opus 디코드 → PCM으로 만들어 STT에 흘려보낸다.
|
||||
- `faster-whisper`로 실시간 부분 전사를 계속 돌리고, VAD로 발화 종료(endpointing)를 잡는다.
|
||||
종료 판정 시점엔 전사가 사실상 끝나 있어, 판정 후 남는 건 VAD hangover + 짧은 마지막
|
||||
디코드뿐이다. 목표 종료→텍스트 확정 ~150ms.
|
||||
@@ -128,12 +133,15 @@ ffmpeg 디코드 → 프레임. DAVE는 `@snazzah/davey`(Rust NAPI), 수신은 `
|
||||
|
||||
```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 등).
|
||||
|
||||
---
|
||||
@@ -149,7 +157,8 @@ ffmpeg 디코드 → 프레임. DAVE는 `@snazzah/davey`(Rust NAPI), 수신은 `
|
||||
| `wsai/backends/mock.py` | 무의존성 mock 전 계열 |
|
||||
| `wsai/backends/capture_mss.py` | 로컬 화면 캡처(트랙 B, 보류) |
|
||||
| `wsai/backends/claude.py` | Claude 비전 + 두뇌 |
|
||||
| `dave/gate.mjs` | DAVE/MLS E2EE 합류 관문 검증(통과 증거, 눈 재개 시 재사용) |
|
||||
| `dave/bot.mjs` | **공식 봇** 보이스 접속 + 유저별 Opus 수신(DAVE E2EE, 현재 경로) |
|
||||
| `dave/gate.mjs`, `dave/join.mjs` | 레거시 셀프봇 경로(폐기, 비디오 트랙 재개 시 참고용) |
|
||||
| `poc/` | Xvfb+Chromium 캡처 실험(트랙 B 참고) |
|
||||
| `PLAN.md` | 단계별 착수 계획 |
|
||||
| `tests/test_pipeline.py` | 파이프라인 스모크 테스트 |
|
||||
@@ -165,8 +174,9 @@ ffmpeg 디코드 → 프레임. DAVE는 `@snazzah/davey`(Rust NAPI), 수신은 `
|
||||
· 검증: `python -m wsai --voice`가 화면 없이 발화마다 응답을 낸다(테스트 포함).
|
||||
- [ ] **V1 · 진짜 TTS(입).** MeloTTS로 두뇌 응답을 실제 음성으로 합성.
|
||||
· 검증: 응답 텍스트가 .wav로 합성돼 들린다.
|
||||
- [ ] **V2 · 진짜 STT(귀).** faster-whisper로 오디오를 텍스트로 전사.
|
||||
· 검증: 오디오 파일 입력이 텍스트로 나온다(이후 디스코드 보이스 수신 오디오로 연결).
|
||||
- [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 스트리밍, 말 끊기, 지연 측정.
|
||||
@@ -183,17 +193,76 @@ ffmpeg 디코드 → 프레임. DAVE는 `@snazzah/davey`(Rust NAPI), 수신은 `
|
||||
첫 소리 지연을 GPU에서 실측해, 1초 예산에 맞는 가장 자연스러운 엔진을 채택한다(측정 기반 선택).
|
||||
- **두뇌** — Claude **OAuth**, 이 호스트에 연결된 것과 **동일 크리덴셜 공유**
|
||||
(`/home/claude/EJClaw/data/claude/.credentials.json`). API키 아님.
|
||||
- **STT 입력 경로** — 디스코드 보이스 수신(셀프봇). 유저 음성 Opus RTP를 받아 DAVE 복호·디코드해 STT로.
|
||||
- **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_SELFBOT_TOKEN`(버너 계정, 밴 리스크 수용).
|
||||
- 봇 토큰: `.env`의 `DISCORD_BOT_TOKEN`(테스트봇, app id `1538122882528321536`). ToS-safe.
|
||||
- .9 로컬 GPU 도커 이미지로 올려, 그 채널에 접속한 뒤 사람이 말하면 대화하도록 한다.
|
||||
- 첫 로딩 워밍업: 시작 시 모델 프리로드 + 더미 추론(CUDA 워밍) + 보이스 미리 접속 → 첫 대화도 지연 최소.
|
||||
|
||||
### 구현 마일스톤
|
||||
- **M1** 셀프봇이 대상 보이스 채널에 상주 접속(DAVE 통과) + 발화자(SSRC) 감지 ← 지금
|
||||
- **M2** 유저 음성 Opus RTP 수신 → DAVE 복호 → PCM (라이브 발화자 필요, 최고 난이도)
|
||||
- **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분할, 발화자(🗣)·서버/채널(🔊)
|
||||
표시, 단계별·총 소요시간. 대화 로그는 **시간·유저(발화자)·서버·채널·내용**으로 필터한다
|
||||
(턴에 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`, 순수 함수) |
|
||||
|
||||
353
dave/bot.mjs
Normal file
353
dave/bot.mjs
Normal file
@@ -0,0 +1,353 @@
|
||||
// Official Discord BOT voice joiner (replaces the selfbot join.mjs).
|
||||
//
|
||||
// Why this exists: the project migrated off the user-token selfbot path onto an
|
||||
// official bot application. For the voice loop (STT input) this is the fully
|
||||
// supported, ToS-safe path — @discordjs/voice lets a bot JOIN a voice channel,
|
||||
// pass the DAVE/MLS E2EE handshake (via @snazzah/davey, handled internally), and
|
||||
// RECEIVE per-user Opus audio. Only screenshare VIDEO receive still requires a
|
||||
// selfbot, and video ("눈") is deferred, so nothing here needs a user token.
|
||||
//
|
||||
// This replicates M1 (join target channel, stay, detect speakers) and, for free,
|
||||
// gives partial M2: it decodes each speaker's Opus to PCM and counts frames — the
|
||||
// exact stream the STT stage will consume.
|
||||
//
|
||||
// Usage:
|
||||
// node bot.mjs # join and stay until killed
|
||||
// RUN_MS=15000 node bot.mjs # join, hold 15s, then leave (for verification)
|
||||
//
|
||||
// Requires: the bot must be INVITED to the target guild with the "Connect" and
|
||||
// "Speak" voice permissions. See dave/README or run `node bot.mjs --invite`.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
Client,
|
||||
GatewayIntentBits,
|
||||
} from 'discord.js';
|
||||
import {
|
||||
joinVoiceChannel,
|
||||
getVoiceConnection,
|
||||
entersState,
|
||||
VoiceConnectionStatus,
|
||||
EndBehaviorType,
|
||||
createAudioPlayer,
|
||||
createAudioResource,
|
||||
StreamType,
|
||||
NoSubscriberBehavior,
|
||||
} from '@discordjs/voice';
|
||||
import prism from 'prism-media';
|
||||
import { isAllowed } from './filter.mjs';
|
||||
|
||||
// ---------- config ----------
|
||||
function loadEnvFile() {
|
||||
try {
|
||||
return Object.fromEntries(
|
||||
fs.readFileSync(new URL('../.env', import.meta.url), 'utf8')
|
||||
.split('\n').filter(l => l && !l.startsWith('#') && l.includes('='))
|
||||
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
|
||||
);
|
||||
} catch { return {}; }
|
||||
}
|
||||
const env = loadEnvFile();
|
||||
const TOKEN = process.env.DISCORD_BOT_TOKEN || env.DISCORD_BOT_TOKEN;
|
||||
const GUILD_ID = process.env.GUILD_ID || env.GUILD_ID || '1352269198297923648';
|
||||
const CHANNEL_ID = process.env.CHANNEL_ID || env.CHANNEL_ID || '1352269198914621465';
|
||||
const RUN_MS = process.env.RUN_MS != null ? Number(process.env.RUN_MS) : 0; // 0 = stay forever
|
||||
// Python STT+TTS voice-turn endpoint (run `python -m wsai --voice-server`).
|
||||
const VOICE_ENDPOINT = process.env.WSAI_VOICE_ENDPOINT || env.WSAI_VOICE_ENDPOINT
|
||||
|| 'http://127.0.0.1:8787/api/voice-turn';
|
||||
// Dashboard control plane (state push + command poll), same host as voice-turn.
|
||||
const API_BASE = VOICE_ENDPOINT.replace(/\/api\/voice-turn\/?$/, '');
|
||||
const REPORT_ENDPOINT = API_BASE + '/api/bot/report';
|
||||
const REPORT_INTERVAL_MS = Number(process.env.WSAI_REPORT_INTERVAL_MS || 2500);
|
||||
// Ignore utterances shorter than this many PCM bytes (48kHz*2ch*2B = 192000 B/s),
|
||||
// so key clicks / brief noise don't trigger a turn. ~0.35s.
|
||||
const MIN_UTTERANCE_BYTES = Number(process.env.WSAI_MIN_UTTERANCE_BYTES || 67000);
|
||||
|
||||
const t0 = Date.now();
|
||||
const log = (...a) => console.log(`[+${String(Date.now() - t0).padStart(6)}ms]`, ...a);
|
||||
|
||||
// Throttle bursty repeated logs. DAVE (E2EE) group transitions — someone joins
|
||||
// or leaves the voice channel — briefly deliver undecryptable packets, so the
|
||||
// same "recv stream error" can fire many times in a second. Log the first
|
||||
// occurrence of a given message immediately, then collapse repeats within a
|
||||
// window into one summary line instead of flooding the log.
|
||||
const _throttle = new Map(); // key -> { count, timer }
|
||||
function logThrottled(key, msg, windowMs = 10_000) {
|
||||
const e = _throttle.get(key);
|
||||
if (e) { e.count++; return; }
|
||||
log(msg);
|
||||
const timer = setTimeout(() => {
|
||||
const cur = _throttle.get(key);
|
||||
_throttle.delete(key);
|
||||
if (cur && cur.count > 0) log(`${msg} (+${cur.count} more in ${Math.round(windowMs / 1000)}s)`);
|
||||
}, windowMs);
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
_throttle.set(key, { count: 0, timer });
|
||||
}
|
||||
|
||||
// PCM s16le -> WAV container (so the Python side can ffmpeg-decode it).
|
||||
function wavHeader(dataLen, sampleRate = 48000, channels = 2, bits = 16) {
|
||||
const blockAlign = channels * bits / 8;
|
||||
const b = Buffer.alloc(44);
|
||||
b.write('RIFF', 0); b.writeUInt32LE(36 + dataLen, 4); b.write('WAVE', 8);
|
||||
b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20);
|
||||
b.writeUInt16LE(channels, 22); b.writeUInt32LE(sampleRate, 24);
|
||||
b.writeUInt32LE(sampleRate * blockAlign, 28); b.writeUInt16LE(blockAlign, 32);
|
||||
b.writeUInt16LE(bits, 34); b.write('data', 36); b.writeUInt32LE(dataLen, 40);
|
||||
return b;
|
||||
}
|
||||
|
||||
// Speak audio into the voice channel. Feed the reply wav bytes through ffmpeg
|
||||
// (StreamType.Arbitrary) so @discordjs/voice re-encodes to Opus.
|
||||
let voicePlayer = null;
|
||||
function playReply(wavBytes) {
|
||||
if (!voicePlayer || !wavBytes || wavBytes.length === 0) return;
|
||||
const resource = createAudioResource(Readable.from(wavBytes), { inputType: StreamType.Arbitrary });
|
||||
voicePlayer.play(resource);
|
||||
}
|
||||
|
||||
// One utterance: PCM -> WAV -> POST to Python -> play the reply back.
|
||||
async function handleUtterance(userId, pcm) {
|
||||
if (pcm.length < MIN_UTTERANCE_BYTES) {
|
||||
log(`utterance too short user=${userId} bytes=${pcm.length} — skip`);
|
||||
return;
|
||||
}
|
||||
const wav = Buffer.concat([wavHeader(pcm.length), pcm]);
|
||||
// Resolve who spoke (Discord display name) so the dashboard log can show it.
|
||||
let speaker = userId;
|
||||
try {
|
||||
const g = currentGuildId && client.guilds.cache.get(currentGuildId);
|
||||
const m = g && (g.members.cache.get(userId) || await g.members.fetch(userId).catch(() => null));
|
||||
if (m) speaker = m.displayName || m.user.username;
|
||||
} catch {}
|
||||
let resp;
|
||||
try {
|
||||
const guildName = (currentGuildId && client.guilds.cache.get(currentGuildId)?.name) || '';
|
||||
resp = await fetch(VOICE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'audio/wav',
|
||||
'X-User-Name': encodeURIComponent(speaker),
|
||||
'X-Guild-Name': encodeURIComponent(guildName),
|
||||
'X-Channel-Name': encodeURIComponent(currentChannelName || ''),
|
||||
},
|
||||
body: wav,
|
||||
});
|
||||
} catch (e) {
|
||||
log(`voice-turn POST failed (is \`python -m wsai --voice-server\` running?): ${e.message}`);
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) { log(`voice-turn HTTP ${resp.status}`); return; }
|
||||
const heard = decodeURIComponent(resp.headers.get('X-Heard') || '');
|
||||
const reply = decodeURIComponent(resp.headers.get('X-Reply') || '');
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
log(`heard="${heard}" reply="${reply}" replyWav=${buf.length}B`);
|
||||
playReply(buf);
|
||||
}
|
||||
|
||||
// ---------- invite URL helper ----------
|
||||
// A bot cannot add itself to a server; a server admin must click an OAuth2 invite
|
||||
// URL once. Print it so the user can authorise the bot with voice permissions.
|
||||
if (process.argv.includes('--invite')) {
|
||||
const APP_ID = process.env.DISCORD_APP_ID || env.DISCORD_APP_ID || '';
|
||||
// permissions: Connect(1<<20) | Speak(1<<21) | UseVAD(1<<25) | ViewChannel(1<<10)
|
||||
const perms = (1n << 20n) | (1n << 21n) | (1n << 25n) | (1n << 10n);
|
||||
if (!APP_ID) { console.error('set DISCORD_APP_ID (application/client id) in .env to build the invite URL'); process.exit(2); }
|
||||
console.log(`https://discord.com/oauth2/authorize?client_id=${APP_ID}&scope=bot&permissions=${perms}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!TOKEN) { console.error('no DISCORD_BOT_TOKEN in env or ../.env'); process.exit(2); }
|
||||
|
||||
// ---------- client ----------
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
|
||||
});
|
||||
|
||||
const perUser = new Map(); // userId -> { opusPackets, pcmFrames }
|
||||
|
||||
// --- control-plane state (dashboard drives which channel we're in) --------- #
|
||||
let currentGuildId = null, currentChannelId = null, currentChannelName = null;
|
||||
const speakingSet = new Set(); // userIds currently speaking (for participant list)
|
||||
const activeSubs = new Set(); // userIds with an in-flight receive subscription
|
||||
let listsByGuild = {}; // guildId -> {whitelistUsers, blacklistUsers, whitelistRoles, blacklistRoles}
|
||||
|
||||
// Attach the bot's audio player (so it can speak) to a fresh connection.
|
||||
function setupPlayer(connection) {
|
||||
voicePlayer = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||||
voicePlayer.on('error', (e) => log(`player error: ${e.message}`));
|
||||
connection.subscribe(voicePlayer);
|
||||
}
|
||||
|
||||
// Attach the receive path: capture each utterance and run the voice turn.
|
||||
function setupReceiver(connection) {
|
||||
const receiver = connection.receiver;
|
||||
receiver.speaking.on('start', (userId) => {
|
||||
speakingSet.add(userId);
|
||||
if (userId === client.user.id || activeSubs.has(userId)) return;
|
||||
// Whitelist/blacklist: skip speakers we are configured not to listen to.
|
||||
const lists = listsByGuild[currentGuildId];
|
||||
if (lists) {
|
||||
const member = client.guilds.cache.get(currentGuildId)?.members.cache.get(userId);
|
||||
const roleIds = member ? [...member.roles.cache.keys()] : [];
|
||||
if (!isAllowed(userId, roleIds, lists)) {
|
||||
logThrottled(`skip:${userId}`, `skip user=${userId} (listen filter)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
activeSubs.add(userId);
|
||||
if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 });
|
||||
const opusStream = receiver.subscribe(userId, {
|
||||
end: { behavior: EndBehaviorType.AfterSilence, duration: 800 },
|
||||
});
|
||||
const decoder = new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 });
|
||||
const chunks = [];
|
||||
opusStream.on('data', () => { perUser.get(userId).opusPackets++; });
|
||||
opusStream.on('error', (e) => { logThrottled(`recv:${e.message}`, `recv stream error user=${userId}: ${e.message}`); activeSubs.delete(userId); });
|
||||
opusStream.pipe(decoder);
|
||||
decoder.on('data', (d) => { chunks.push(d); perUser.get(userId).pcmFrames++; });
|
||||
decoder.on('error', (e) => log(`decode error user=${userId}: ${e.message}`));
|
||||
decoder.on('end', () => {
|
||||
activeSubs.delete(userId);
|
||||
const pcm = Buffer.concat(chunks);
|
||||
log(`utterance end user=${userId} pcm=${pcm.length}B — running voice turn`);
|
||||
handleUtterance(userId, pcm).catch((e) => log(`voice turn error: ${e.message}`));
|
||||
});
|
||||
});
|
||||
receiver.speaking.on('end', (userId) => speakingSet.delete(userId));
|
||||
}
|
||||
|
||||
// Join (or switch to) a voice channel on command from the dashboard.
|
||||
async function joinChannel(guildId, channelId) {
|
||||
const guild = await client.guilds.fetch(guildId).catch(() => null);
|
||||
const channel = guild && await guild.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel || !channel.isVoiceBased()) { log(`join failed: ${guildId}/${channelId} not a voice channel`); return; }
|
||||
if (currentGuildId === guildId && currentChannelId === channelId && getVoiceConnection(guildId)) {
|
||||
log(`already in "${channel.name}"`); return;
|
||||
}
|
||||
// Always tear down any existing connection first. Re-subscribing a player and
|
||||
// receiver onto a reused same-guild connection would stack duplicate speaking
|
||||
// listeners (→ duplicate voice turns) and duplicate error handlers.
|
||||
if (currentGuildId) leaveChannel();
|
||||
const connection = joinVoiceChannel({
|
||||
channelId, guildId, adapterCreator: guild.voiceAdapterCreator,
|
||||
selfDeaf: false, selfMute: false,
|
||||
});
|
||||
connection.on('error', (e) => log(`voice connection error: ${e.message}`));
|
||||
try {
|
||||
await entersState(connection, VoiceConnectionStatus.Ready, 40_000);
|
||||
} catch (e) { log(`join not Ready in 40s: ${e.message}`); return; }
|
||||
currentGuildId = guildId; currentChannelId = channelId; currentChannelName = channel.name;
|
||||
speakingSet.clear(); activeSubs.clear();
|
||||
setupPlayer(connection);
|
||||
setupReceiver(connection);
|
||||
connection.on(VoiceConnectionStatus.Disconnected, () => {
|
||||
log('voice: disconnected — attempting to resume…');
|
||||
Promise.race([
|
||||
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
||||
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
||||
]).catch(() => { log('voice: could not resume'); leaveChannel(); });
|
||||
});
|
||||
log(`✅ joined voice: "${guild.name}" / "${channel.name}"`);
|
||||
}
|
||||
|
||||
function leaveChannel() {
|
||||
try { getVoiceConnection(currentGuildId)?.destroy(); } catch {}
|
||||
currentGuildId = currentChannelId = currentChannelName = null;
|
||||
speakingSet.clear(); activeSubs.clear();
|
||||
}
|
||||
|
||||
// Build the state snapshot the dashboard shows (identity, joinable servers +
|
||||
// voice channels, current channel, and who is in it).
|
||||
function buildState() {
|
||||
const guilds = [...client.guilds.cache.values()].map((g) => ({
|
||||
id: g.id, name: g.name,
|
||||
voiceChannels: [...g.channels.cache.values()]
|
||||
.filter((c) => c.isVoiceBased())
|
||||
.map((c) => ({ id: c.id, name: c.name })),
|
||||
// Roles (minus @everyone) and known members, so the dashboard's
|
||||
// whitelist/blacklist popup can search by user OR role.
|
||||
roles: [...g.roles.cache.values()]
|
||||
.filter((r) => r.id !== g.id && r.name !== '@everyone')
|
||||
.map((r) => ({ id: r.id, name: r.name })),
|
||||
members: [...g.members.cache.values()].slice(0, 200).map((m) => ({
|
||||
id: m.id, name: m.displayName, bot: m.user?.bot === true,
|
||||
roleIds: [...m.roles.cache.keys()],
|
||||
})),
|
||||
}));
|
||||
let members = [];
|
||||
if (currentGuildId && currentChannelId) {
|
||||
const ch = client.guilds.cache.get(currentGuildId)?.channels.cache.get(currentChannelId);
|
||||
if (ch && ch.members) {
|
||||
members = [...ch.members.values()].map((m) => ({
|
||||
id: m.id, name: m.displayName, speaking: speakingSet.has(m.id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
return {
|
||||
identity: { id: client.user.id, username: client.user.username, tag: client.user.tag },
|
||||
guilds,
|
||||
current: { guildId: currentGuildId, channelId: currentChannelId, channelName: currentChannelName },
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCommand(cmd) {
|
||||
if (cmd.type === 'join') await joinChannel(cmd.guildId, cmd.channelId);
|
||||
else if (cmd.type === 'leave') { leaveChannel(); log('left voice on command'); }
|
||||
}
|
||||
|
||||
// Push state to the dashboard and apply any commands it hands back.
|
||||
async function reportLoop() {
|
||||
let j;
|
||||
try {
|
||||
const r = await fetch(REPORT_ENDPOINT, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildState()),
|
||||
});
|
||||
j = await r.json();
|
||||
} catch { return; } // dashboard down: keep running, retry next tick
|
||||
if (j?.lists) listsByGuild = j.lists; // latest whitelist/blacklist config
|
||||
for (const cmd of (j?.commands || [])) {
|
||||
try { await handleCommand(cmd); } catch (e) { log(`command ${cmd?.type} failed: ${e.message}`); }
|
||||
}
|
||||
}
|
||||
|
||||
let leaving = false;
|
||||
function leaveAndExit(code = 0) {
|
||||
if (leaving) return;
|
||||
leaving = true;
|
||||
try { getVoiceConnection(currentGuildId || GUILD_ID)?.destroy(); } catch {}
|
||||
const summary = [...perUser.entries()].map(([u, s]) => `${u}:opus=${s.opusPackets},pcm=${s.pcmFrames}`);
|
||||
log(`leaving. speakers heard: ${summary.length ? summary.join(' ') : '(none)'}`);
|
||||
try { client.destroy(); } catch {}
|
||||
setTimeout(() => process.exit(code), 300);
|
||||
}
|
||||
process.on('SIGINT', () => { log('SIGINT'); leaveAndExit(0); });
|
||||
process.on('SIGTERM', () => { log('SIGTERM'); leaveAndExit(0); });
|
||||
|
||||
// Hard time-box ceiling, armed at startup regardless of handshake state.
|
||||
if (RUN_MS > 0) setTimeout(() => { log(`RUN_MS=${RUN_MS} hard ceiling elapsed — leaving`); leaveAndExit(0); }, RUN_MS);
|
||||
|
||||
client.once('clientReady', async () => {
|
||||
log(`logged in as ${client.user.tag} (${client.user.id})`);
|
||||
log(`voice endpoint: ${VOICE_ENDPOINT} · control: ${REPORT_ENDPOINT}`);
|
||||
|
||||
// Backward-compat: if a default guild/channel is configured, auto-join it.
|
||||
// Otherwise idle and wait for the dashboard to pick a channel.
|
||||
if (GUILD_ID && CHANNEL_ID) {
|
||||
await joinChannel(GUILD_ID, CHANNEL_ID).catch((e) => log(`initial join failed: ${e.message}`));
|
||||
} else {
|
||||
log('no default channel — waiting for the dashboard to select a server/voice channel…');
|
||||
}
|
||||
|
||||
// Report state + poll commands forever. This is what powers the dashboard's
|
||||
// bot info, server/voice-channel pickers, participant list, and join/leave.
|
||||
reportLoop();
|
||||
const reportTimer = setInterval(reportLoop, REPORT_INTERVAL_MS);
|
||||
if (typeof reportTimer.unref === 'function') reportTimer.unref();
|
||||
});
|
||||
|
||||
client.on('error', (e) => log('client error', e.message));
|
||||
client.login(TOKEN).catch((e) => { log(`FATAL: login failed — ${e.message}`); process.exit(1); });
|
||||
18
dave/filter.mjs
Normal file
18
dave/filter.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
// Listen filter (whitelist/blacklist) — pure, so it is unit-testable without
|
||||
// booting the Discord client.
|
||||
//
|
||||
// Rules:
|
||||
// - a blacklisted user OR a user with a blacklisted role is always excluded;
|
||||
// - if any whitelist (user or role) is set, only whitelisted speakers pass;
|
||||
// - otherwise everyone passes.
|
||||
export function isAllowed(userId, roleIds, lists) {
|
||||
const L = lists || {};
|
||||
const idSet = (arr) => new Set((arr || []).map((x) => x.id));
|
||||
const roles = new Set(roleIds || []);
|
||||
const hasRole = (arr) => (arr || []).some((r) => roles.has(r.id));
|
||||
if (idSet(L.blacklistUsers).has(userId) || hasRole(L.blacklistRoles)) return false;
|
||||
const wlUsers = L.whitelistUsers || [];
|
||||
const wlRoles = L.whitelistRoles || [];
|
||||
if (wlUsers.length === 0 && wlRoles.length === 0) return true;
|
||||
return idSet(wlUsers).has(userId) || hasRole(wlRoles);
|
||||
}
|
||||
321
dave/gate.mjs
Normal file
321
dave/gate.mjs
Normal file
@@ -0,0 +1,321 @@
|
||||
// Fail-fast gate (arbiter checkpoint #1):
|
||||
// Can a selfbot pass the Discord voice DAVE/MLS handshake (no close 4017),
|
||||
// and does the voice gateway negotiate DAVE + push the external-sender (op25)?
|
||||
//
|
||||
// This intentionally stops at "gate evidence": we log the voice IDENTIFY result,
|
||||
// whether close code 4017 occurs, the negotiated dave_protocol_version, and any
|
||||
// DAVE opcodes the server pushes. It joins a voice channel muted+deaf and leaves
|
||||
// immediately after collecting evidence. No media is transmitted.
|
||||
//
|
||||
// Usage: DAVE_VER=1 node gate.mjs (declare DAVE support)
|
||||
// DAVE_VER=0 node gate.mjs (declare DAVE UNsupported -> expect rejection)
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import dgram from 'node:dgram';
|
||||
import fs from 'node:fs';
|
||||
import * as davey from '@snazzah/davey';
|
||||
|
||||
// --- config ---
|
||||
const env = Object.fromEntries(
|
||||
fs.readFileSync(new URL('../.env', import.meta.url), 'utf8')
|
||||
.split('\n').filter(l => l && !l.startsWith('#') && l.includes('='))
|
||||
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
|
||||
);
|
||||
const TOKEN = env.DISCORD_SELFBOT_TOKEN;
|
||||
const GUILD_ID = process.env.GUILD_ID || '1352269198297923648';
|
||||
const CHANNEL_ID = process.env.CHANNEL_ID || '1352269198914621465'; // "일반"
|
||||
const DAVE_VER = process.env.DAVE_VER != null ? Number(process.env.DAVE_VER) : davey.DAVE_PROTOCOL_VERSION;
|
||||
const HARD_TIMEOUT_MS = Number(process.env.TIMEOUT_MS || 30000);
|
||||
|
||||
if (!TOKEN) { console.error('no token'); process.exit(2); }
|
||||
|
||||
const t0 = Date.now();
|
||||
const log = (...a) => console.log(`[+${String(Date.now() - t0).padStart(6)}ms]`, ...a);
|
||||
console.log(`davey VERSION=${davey.VERSION} DAVE_PROTOCOL_VERSION=${davey.DAVE_PROTOCOL_VERSION}`);
|
||||
log(`gate start: declaring max_dave_protocol_version=${DAVE_VER}, channel=${CHANNEL_ID}`);
|
||||
|
||||
const evidence = {
|
||||
daveVerDeclared: DAVE_VER,
|
||||
mainReady: false,
|
||||
voiceServerReceived: false,
|
||||
voiceIdentifySent: false,
|
||||
voiceReady: false,
|
||||
voiceReadyModes: null,
|
||||
negotiatedDaveVersion: null,
|
||||
sawExternalSender: false,
|
||||
sawDaveOpcodes: [],
|
||||
mlsSessionReady: false,
|
||||
voiceCloseCode: null,
|
||||
voiceCloseReason: null,
|
||||
verdict: 'INCOMPLETE',
|
||||
notes: [],
|
||||
};
|
||||
|
||||
let mainWs, voiceWs, udp;
|
||||
let daveSession = null;
|
||||
let voiceState = { session_id: null, token: null, endpoint: null, ssrc: null, ip: null, port: null, mode: null };
|
||||
let finished = false;
|
||||
|
||||
function finish(verdict, extra) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
evidence.verdict = verdict;
|
||||
if (extra) evidence.notes.push(extra);
|
||||
// leave the voice channel politely
|
||||
try { mainWs?.send(JSON.stringify({ op: 4, d: { guild_id: GUILD_ID, channel_id: null, self_mute: true, self_deaf: true } })); } catch {}
|
||||
setTimeout(() => {
|
||||
try { voiceWs?.close(); } catch {}
|
||||
try { udp?.close(); } catch {}
|
||||
try { mainWs?.close(); } catch {}
|
||||
console.log('\n===== GATE EVIDENCE =====');
|
||||
console.log(JSON.stringify(evidence, null, 2));
|
||||
process.exit(0);
|
||||
}, 400);
|
||||
}
|
||||
setTimeout(() => finish(evidence.verdict === 'INCOMPLETE' ? 'TIMEOUT' : evidence.verdict, `hard timeout ${HARD_TIMEOUT_MS}ms`), HARD_TIMEOUT_MS);
|
||||
|
||||
// ---------- MAIN GATEWAY ----------
|
||||
mainWs = new WebSocket('wss://gateway.discord.gg/?v=10&encoding=json');
|
||||
let mainHb;
|
||||
mainWs.on('open', () => log('main gw: open'));
|
||||
mainWs.on('message', (raw) => {
|
||||
const p = JSON.parse(raw.toString());
|
||||
if (p.op === 10) {
|
||||
const iv = p.d.heartbeat_interval;
|
||||
mainHb = setInterval(() => { try { mainWs.send(JSON.stringify({ op: 1, d: null })); } catch {} }, iv);
|
||||
mainWs.send(JSON.stringify({ op: 2, d: {
|
||||
token: TOKEN,
|
||||
capabilities: 16381,
|
||||
properties: { os: 'Linux', browser: 'Chrome', device: '', system_locale: 'en-US', browser_user_agent: 'Mozilla/5.0', browser_version: '124.0', os_version: '', release_channel: 'stable', client_build_number: 300000 },
|
||||
compress: false,
|
||||
presence: { status: 'invisible', since: 0, activities: [], afk: false },
|
||||
}}));
|
||||
log('main gw: sent IDENTIFY');
|
||||
} else if (p.op === 0) {
|
||||
if (p.t === 'READY') {
|
||||
evidence.mainReady = true;
|
||||
log(`main gw: READY as ${p.d.user?.username} (${p.d.user?.id})`);
|
||||
// join voice channel muted+deaf
|
||||
mainWs.send(JSON.stringify({ op: 4, d: { guild_id: GUILD_ID, channel_id: CHANNEL_ID, self_mute: true, self_deaf: true } }));
|
||||
log('main gw: sent Voice State Update (join)');
|
||||
} else if (p.t === 'VOICE_STATE_UPDATE' && p.d.user_id) {
|
||||
if (p.d.session_id) { voiceState.session_id = p.d.session_id; log('VOICE_STATE_UPDATE session_id acquired'); maybeConnectVoice(); }
|
||||
} else if (p.t === 'VOICE_SERVER_UPDATE') {
|
||||
voiceState.token = p.d.token; voiceState.endpoint = p.d.endpoint;
|
||||
evidence.voiceServerReceived = true;
|
||||
log(`VOICE_SERVER_UPDATE endpoint=${p.d.endpoint}`);
|
||||
maybeConnectVoice();
|
||||
}
|
||||
}
|
||||
});
|
||||
mainWs.on('close', (c, r) => { log(`main gw: close ${c} ${r}`); clearInterval(mainHb); });
|
||||
mainWs.on('error', (e) => log('main gw error', e.message));
|
||||
|
||||
// ---------- VOICE GATEWAY ----------
|
||||
function maybeConnectVoice() {
|
||||
if (voiceWs || !voiceState.session_id || !voiceState.token || !voiceState.endpoint) return;
|
||||
const url = `wss://${voiceState.endpoint}/?v=8`;
|
||||
log(`voice gw: connecting ${url}`);
|
||||
voiceWs = new WebSocket(url);
|
||||
let voiceHb, lastSeq = null;
|
||||
const knownUsers = new Set(['1513862586112671786']);
|
||||
voiceWs.on('open', () => {
|
||||
voiceWs.send(JSON.stringify({ op: 0, d: {
|
||||
server_id: GUILD_ID,
|
||||
user_id: '1513862586112671786',
|
||||
session_id: voiceState.session_id,
|
||||
token: voiceState.token,
|
||||
max_dave_protocol_version: DAVE_VER,
|
||||
}}));
|
||||
evidence.voiceIdentifySent = true;
|
||||
log(`voice gw: sent IDENTIFY (max_dave_protocol_version=${DAVE_VER})`);
|
||||
});
|
||||
voiceWs.on('message', (raw, isBinary) => {
|
||||
if (isBinary) {
|
||||
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||
// received binary DAVE frame: uint16 seq BE, uint8 opcode, payload
|
||||
const seq = buf.readUInt16BE(0);
|
||||
const op = buf.readUInt8(2);
|
||||
const payload = buf.subarray(3);
|
||||
lastSeq = seq;
|
||||
onDaveBinary(op, payload, seq);
|
||||
return;
|
||||
}
|
||||
const p = JSON.parse(raw.toString());
|
||||
handleVoiceJson(p);
|
||||
});
|
||||
voiceWs.on('close', (c, r) => {
|
||||
evidence.voiceCloseCode = c; evidence.voiceCloseReason = r?.toString() || '';
|
||||
log(`voice gw: CLOSE code=${c} reason="${evidence.voiceCloseReason}"`);
|
||||
clearInterval(voiceHb);
|
||||
if (c === 4017) finish('FAIL_4017', 'voice gateway rejected with close 4017 (DAVE-unsupported/protocol)');
|
||||
else if (!finished) finish(evidence.voiceReady ? evidence.verdict : 'FAIL_VOICE_CLOSED', `voice closed ${c}`);
|
||||
});
|
||||
voiceWs.on('error', (e) => log('voice gw error', e.message));
|
||||
|
||||
function handleVoiceJson(p) {
|
||||
switch (p.op) {
|
||||
case 8: { // HELLO
|
||||
const iv = p.d.heartbeat_interval;
|
||||
voiceHb = setInterval(() => {
|
||||
try { voiceWs.send(JSON.stringify({ op: 3, d: { t: Date.now(), seq_ack: lastSeq ?? 0 } })); } catch {}
|
||||
}, iv);
|
||||
log(`voice gw: HELLO heartbeat_interval=${iv}`);
|
||||
break;
|
||||
}
|
||||
case 2: { // READY
|
||||
evidence.voiceReady = true;
|
||||
evidence.voiceReadyModes = p.d.modes;
|
||||
voiceState.ssrc = p.d.ssrc; voiceState.ip = p.d.ip; voiceState.port = p.d.port;
|
||||
log(`voice gw: READY ssrc=${p.d.ssrc} udp=${p.d.ip}:${p.d.port} modes=${JSON.stringify(p.d.modes)}`);
|
||||
doUdpDiscoveryAndSelect(p.d);
|
||||
break;
|
||||
}
|
||||
case 4: { // Session Description / select_protocol_ack (carries dave_protocol_version)
|
||||
if (p.d && p.d.dave_protocol_version != null) {
|
||||
evidence.negotiatedDaveVersion = p.d.dave_protocol_version;
|
||||
log(`voice gw: SESSION_DESCRIPTION dave_protocol_version=${p.d.dave_protocol_version} mode=${p.d.mode}`);
|
||||
} else {
|
||||
log(`voice gw: SESSION_DESCRIPTION (no dave version field) mode=${p.d?.mode}`);
|
||||
}
|
||||
evaluateGate();
|
||||
break;
|
||||
}
|
||||
case 11: { // clients connect
|
||||
for (const u of (p.d.user_ids || [])) knownUsers.add(u);
|
||||
log(`voice gw: op11 clients_connect ${JSON.stringify(p.d.user_ids)}`);
|
||||
break;
|
||||
}
|
||||
case 20: { if (p.d.user_id) knownUsers.add(p.d.user_id); log(`voice gw: op20 platform user=${p.d.user_id}`); break; }
|
||||
case 21: { // dave_prepare_transition (JSON)
|
||||
evidence.sawDaveOpcodes.push(21);
|
||||
log(`voice gw: DAVE op21 prepare_transition ${JSON.stringify(p.d)}`);
|
||||
// ack readiness
|
||||
try { voiceWs.send(JSON.stringify({ op: 23, d: { transition_id: p.d.transition_id } })); } catch {}
|
||||
break;
|
||||
}
|
||||
case 22: {
|
||||
evidence.sawDaveOpcodes.push(22);
|
||||
log(`voice gw: DAVE op22 execute_transition ${JSON.stringify(p.d)}`);
|
||||
if (daveSession && daveSession.ready) { evidence.mlsSessionReady = true; }
|
||||
evaluateGate();
|
||||
break;
|
||||
}
|
||||
case 24: {
|
||||
evidence.sawDaveOpcodes.push(24);
|
||||
log(`voice gw: DAVE op24 prepare_epoch ${JSON.stringify(p.d)}`);
|
||||
break;
|
||||
}
|
||||
case 31: {
|
||||
evidence.sawDaveOpcodes.push(31);
|
||||
log(`voice gw: DAVE op31 invalid_commit_welcome ${JSON.stringify(p.d)}`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
log(`voice gw: op${p.op} ${JSON.stringify(p.d)?.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function onDaveBinary(op, payload, seq) {
|
||||
evidence.sawDaveOpcodes.push(op);
|
||||
log(`voice gw: DAVE binary op${op} seq=${seq} len=${payload.length}`);
|
||||
try {
|
||||
switch (op) {
|
||||
case 25: { // external sender package
|
||||
evidence.sawExternalSender = true;
|
||||
if (!daveSession) {
|
||||
daveSession = new davey.DAVESession(evidence.negotiatedDaveVersion || DAVE_VER, '1513862586112671786', CHANNEL_ID);
|
||||
}
|
||||
daveSession.setExternalSender(payload);
|
||||
const kp = daveSession.getSerializedKeyPackage();
|
||||
// send op26 key package (binary): [uint8 opcode][payload]
|
||||
voiceWs.send(Buffer.concat([Buffer.from([26]), kp]), { binary: true });
|
||||
log(`voice gw: sent DAVE op26 key_package len=${kp.length}`);
|
||||
break;
|
||||
}
|
||||
case 27: { // proposals: [operation_type u8][proposals...]
|
||||
const opType = payload.readUInt8(0); // ProposalsOperationType
|
||||
const proposals = payload.subarray(1);
|
||||
const known = Array.from(knownUsers);
|
||||
const res = daveSession.processProposals(opType, proposals, known);
|
||||
if (res && res.commit) {
|
||||
const parts = [Buffer.from([28]), res.commit];
|
||||
if (res.welcome) parts.push(res.welcome);
|
||||
voiceWs.send(Buffer.concat(parts), { binary: true });
|
||||
log(`voice gw: sent DAVE op28 commit_welcome commit=${res.commit.length} welcome=${res.welcome?.length || 0} (known=${known.length})`);
|
||||
} else {
|
||||
log(`voice gw: op27 processed, no commit produced (known=${known.length})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 29: { // announce commit transition: [transition_id u16][commit...]
|
||||
const commit = payload.subarray(2);
|
||||
daveSession.processCommit(commit);
|
||||
if (daveSession.ready) evidence.mlsSessionReady = true;
|
||||
log(`voice gw: processed op29 commit (tid=${payload.readUInt16BE(0)}), sessionReady=${daveSession.ready}`);
|
||||
evaluateGate();
|
||||
break;
|
||||
}
|
||||
case 30: { // welcome: [transition_id u16][welcome...]
|
||||
const welcome = payload.subarray(2);
|
||||
daveSession.processWelcome(welcome);
|
||||
if (daveSession.ready) evidence.mlsSessionReady = true;
|
||||
log(`voice gw: processed op30 welcome (tid=${payload.readUInt16BE(0)}), sessionReady=${daveSession.ready}`);
|
||||
evaluateGate();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
log(`voice gw: unhandled DAVE binary op${op}`);
|
||||
}
|
||||
} catch (e) {
|
||||
evidence.notes.push(`DAVE op${op} error: ${e.message}`);
|
||||
log(`voice gw: DAVE op${op} handler error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function daveKnownUsers() { return ['1513862586112671786']; }
|
||||
|
||||
function doUdpDiscoveryAndSelect(ready) {
|
||||
udp = dgram.createSocket('udp4');
|
||||
const disc = Buffer.alloc(74);
|
||||
disc.writeUInt16BE(1, 0); disc.writeUInt16BE(70, 2); disc.writeUInt32BE(ready.ssrc, 4);
|
||||
let selected = false;
|
||||
udp.on('message', (msg) => {
|
||||
if (selected) return; selected = true;
|
||||
const ipEnd = msg.indexOf(0, 8);
|
||||
const ip = msg.subarray(8, ipEnd).toString();
|
||||
const port = msg.readUInt16BE(msg.length - 2);
|
||||
const mode = (ready.modes || []).includes('aead_aes256_gcm_rtpsize') ? 'aead_aes256_gcm_rtpsize'
|
||||
: (ready.modes || []).includes('aead_xchacha20_poly1305_rtpsize') ? 'aead_xchacha20_poly1305_rtpsize'
|
||||
: (ready.modes || [])[0];
|
||||
voiceState.mode = mode;
|
||||
voiceWs.send(JSON.stringify({ op: 1, d: { protocol: 'udp', data: { address: ip, port, mode }, codecs: [
|
||||
{ name: 'opus', type: 'audio', priority: 1000, payload_type: 120 },
|
||||
{ name: 'VP8', type: 'video', priority: 1000, payload_type: 101, rtx_payload_type: 102 },
|
||||
{ name: 'H264', type: 'video', priority: 2000, payload_type: 103, rtx_payload_type: 104 },
|
||||
] }}));
|
||||
log(`voice gw: sent SELECT PROTOCOL (mode=${mode}) after UDP discovery ${ip}:${port}`);
|
||||
});
|
||||
udp.on('error', (e) => log('udp error', e.message));
|
||||
udp.send(disc, ready.port, ready.ip, (e) => { if (e) log('udp send err', e.message); else log('udp: sent IP discovery'); });
|
||||
}
|
||||
|
||||
function evaluateGate() {
|
||||
if (finished) return;
|
||||
if (!evidence.voiceReady || evidence.negotiatedDaveVersion == null) return;
|
||||
if (evidence.negotiatedDaveVersion === 0) {
|
||||
// DAVE not enforced on this channel right now: gateway accepted, no E2EE membership needed.
|
||||
return finish('PASS_NO_DAVE', 'voice gateway accepted; dave_protocol_version=0 (E2EE not active on this channel)');
|
||||
}
|
||||
if (evidence.mlsSessionReady) {
|
||||
// Full E2EE membership achieved. Hold briefly to confirm the membership stays stable
|
||||
// (no server disconnect), then leave cleanly.
|
||||
log('gate: MLS session READY — holding 5s to confirm stable membership');
|
||||
setTimeout(() => {
|
||||
if (evidence.voiceCloseCode == null) evidence.notes.push('membership stable for 5s (no server disconnect)');
|
||||
finish('PASS_MLS_READY', `DAVE v${evidence.negotiatedDaveVersion} negotiated; joined E2EE MLS group (session ready); privacyCode=${daveSession?.voicePrivacyCode || 'n/a'}`);
|
||||
}, 5000);
|
||||
}
|
||||
// else: DAVE negotiated but MLS not yet complete — keep waiting for op25/27/29/30.
|
||||
}
|
||||
}
|
||||
241
dave/join.mjs
Normal file
241
dave/join.mjs
Normal file
@@ -0,0 +1,241 @@
|
||||
// M1 — persistent selfbot voice join (builds on the proven gate.mjs handshake).
|
||||
//
|
||||
// Difference from gate.mjs: this does NOT leave after collecting evidence. It
|
||||
// joins the target voice channel, completes the DAVE/MLS E2EE membership, then
|
||||
// STAYS connected and reports:
|
||||
// * who is speaking (op5 SPEAKING -> maps audio SSRC to a user id)
|
||||
// * incoming UDP/RTP packets per SSRC (the media path the STT stage will read)
|
||||
//
|
||||
// This is the foundation for M2 (decrypt the incoming Opus and feed STT). No
|
||||
// audio is transmitted yet.
|
||||
//
|
||||
// Usage:
|
||||
// node join.mjs # join and stay until killed
|
||||
// RUN_MS=15000 node join.mjs # join, hold 15s, then leave (for verification)
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import dgram from 'node:dgram';
|
||||
import fs from 'node:fs';
|
||||
import * as davey from '@snazzah/davey';
|
||||
|
||||
const env = Object.fromEntries(
|
||||
fs.readFileSync(new URL('../.env', import.meta.url), 'utf8')
|
||||
.split('\n').filter(l => l && !l.startsWith('#') && l.includes('='))
|
||||
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
|
||||
);
|
||||
const TOKEN = env.DISCORD_SELFBOT_TOKEN;
|
||||
const GUILD_ID = process.env.GUILD_ID || '1352269198297923648';
|
||||
const CHANNEL_ID = process.env.CHANNEL_ID || '1352269198914621465';
|
||||
const SELF_ID = process.env.SELF_ID || '1513862586112671786';
|
||||
const DAVE_VER = process.env.DAVE_VER != null ? Number(process.env.DAVE_VER) : davey.DAVE_PROTOCOL_VERSION;
|
||||
const RUN_MS = process.env.RUN_MS != null ? Number(process.env.RUN_MS) : 0; // 0 = stay forever
|
||||
|
||||
if (!TOKEN) { console.error('no DISCORD_SELFBOT_TOKEN in .env'); process.exit(2); }
|
||||
|
||||
const t0 = Date.now();
|
||||
const log = (...a) => console.log(`[+${String(Date.now() - t0).padStart(6)}ms]`, ...a);
|
||||
console.log(`davey VERSION=${davey.VERSION} DAVE_PROTOCOL_VERSION=${davey.DAVE_PROTOCOL_VERSION}`);
|
||||
log(`join start: channel=${CHANNEL_ID} guild=${GUILD_ID} dave=${DAVE_VER}`);
|
||||
|
||||
let mainWs, voiceWs, udp;
|
||||
let daveSession = null;
|
||||
let mlsReady = false;
|
||||
let discoverySelected = false;
|
||||
const knownUsers = new Set([SELF_ID]);
|
||||
const ssrcToUser = new Map(); // audio ssrc -> user id
|
||||
const rtpCount = new Map(); // ssrc -> packet count
|
||||
const voiceState = { session_id: null, token: null, endpoint: null, ssrc: null, ip: null, port: null, mode: null };
|
||||
|
||||
let leaving = false;
|
||||
function leaveAndExit(code = 0) {
|
||||
if (leaving) return; // idempotent: hard ceiling + ready timer + signals must not double-fire
|
||||
leaving = true;
|
||||
try { mainWs?.send(JSON.stringify({ op: 4, d: { guild_id: GUILD_ID, channel_id: null, self_mute: true, self_deaf: true } })); } catch {}
|
||||
setTimeout(() => {
|
||||
try { voiceWs?.close(); } catch {}
|
||||
try { udp?.close(); } catch {}
|
||||
try { mainWs?.close(); } catch {}
|
||||
log(`leaving. speakers seen: ${JSON.stringify([...ssrcToUser.entries()])}, rtp counts: ${JSON.stringify([...rtpCount.entries()])}`);
|
||||
process.exit(code);
|
||||
}, 400);
|
||||
}
|
||||
process.on('SIGINT', () => { log('SIGINT'); leaveAndExit(0); });
|
||||
process.on('SIGTERM', () => { log('SIGTERM'); leaveAndExit(0); });
|
||||
|
||||
// Hard time-box ceiling, armed at startup regardless of handshake state. Without
|
||||
// this, a partial join (e.g. DAVE/MLS never completes op29/op30 so announceReady
|
||||
// never fires) would run the selfbot forever — a guardrail hole for a live test.
|
||||
if (RUN_MS > 0) setTimeout(() => { log(`RUN_MS=${RUN_MS} hard ceiling elapsed — leaving`); leaveAndExit(0); }, RUN_MS);
|
||||
|
||||
// ---------- MAIN GATEWAY ----------
|
||||
mainWs = new WebSocket('wss://gateway.discord.gg/?v=10&encoding=json');
|
||||
let mainHb;
|
||||
mainWs.on('open', () => log('main gw: open'));
|
||||
mainWs.on('message', (raw) => {
|
||||
const p = JSON.parse(raw.toString());
|
||||
if (p.op === 10) {
|
||||
mainHb = setInterval(() => { try { mainWs.send(JSON.stringify({ op: 1, d: null })); } catch {} }, p.d.heartbeat_interval);
|
||||
mainWs.send(JSON.stringify({ op: 2, d: {
|
||||
token: TOKEN,
|
||||
capabilities: 16381,
|
||||
properties: { os: 'Linux', browser: 'Chrome', device: '', system_locale: 'en-US', browser_user_agent: 'Mozilla/5.0', browser_version: '124.0', os_version: '', release_channel: 'stable', client_build_number: 300000 },
|
||||
compress: false,
|
||||
presence: { status: 'invisible', since: 0, activities: [], afk: false },
|
||||
}}));
|
||||
log('main gw: sent IDENTIFY');
|
||||
} else if (p.op === 0) {
|
||||
if (p.t === 'READY') {
|
||||
log(`main gw: READY as ${p.d.user?.username} (${p.d.user?.id})`);
|
||||
mainWs.send(JSON.stringify({ op: 4, d: { guild_id: GUILD_ID, channel_id: CHANNEL_ID, self_mute: false, self_deaf: false } }));
|
||||
log('main gw: sent Voice State Update (join, mute=false + deaf=false so we can both speak and hear)');
|
||||
} else if (p.t === 'VOICE_STATE_UPDATE' && p.d.user_id === SELF_ID && p.d.session_id) {
|
||||
voiceState.session_id = p.d.session_id; log('VOICE_STATE_UPDATE session_id acquired'); maybeConnectVoice();
|
||||
} else if (p.t === 'VOICE_SERVER_UPDATE') {
|
||||
voiceState.token = p.d.token; voiceState.endpoint = p.d.endpoint;
|
||||
log(`VOICE_SERVER_UPDATE endpoint=${p.d.endpoint}`); maybeConnectVoice();
|
||||
}
|
||||
}
|
||||
});
|
||||
mainWs.on('close', (c, r) => { log(`main gw: close ${c} ${r}`); clearInterval(mainHb); });
|
||||
mainWs.on('error', (e) => log('main gw error', e.message));
|
||||
|
||||
// ---------- VOICE GATEWAY ----------
|
||||
function maybeConnectVoice() {
|
||||
if (voiceWs || !voiceState.session_id || !voiceState.token || !voiceState.endpoint) return;
|
||||
const url = `wss://${voiceState.endpoint}/?v=8`;
|
||||
log(`voice gw: connecting ${url}`);
|
||||
voiceWs = new WebSocket(url);
|
||||
let voiceHb, lastSeq = null;
|
||||
|
||||
voiceWs.on('open', () => {
|
||||
voiceWs.send(JSON.stringify({ op: 0, d: {
|
||||
server_id: GUILD_ID, user_id: SELF_ID, session_id: voiceState.session_id, token: voiceState.token,
|
||||
max_dave_protocol_version: DAVE_VER,
|
||||
}}));
|
||||
log(`voice gw: sent IDENTIFY (max_dave_protocol_version=${DAVE_VER})`);
|
||||
});
|
||||
voiceWs.on('message', (raw, isBinary) => {
|
||||
if (isBinary) {
|
||||
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||
const seq = buf.readUInt16BE(0), op = buf.readUInt8(2), payload = buf.subarray(3);
|
||||
lastSeq = seq; onDaveBinary(op, payload, seq);
|
||||
return;
|
||||
}
|
||||
handleVoiceJson(JSON.parse(raw.toString()));
|
||||
});
|
||||
voiceWs.on('close', (c, r) => {
|
||||
log(`voice gw: CLOSE code=${c} reason="${r?.toString() || ''}"`);
|
||||
clearInterval(voiceHb);
|
||||
if (c === 4017) { log('FATAL: close 4017 (DAVE rejected)'); leaveAndExit(1); }
|
||||
});
|
||||
voiceWs.on('error', (e) => log('voice gw error', e.message));
|
||||
|
||||
function handleVoiceJson(p) {
|
||||
switch (p.op) {
|
||||
case 8:
|
||||
voiceHb = setInterval(() => { try { voiceWs.send(JSON.stringify({ op: 3, d: { t: Date.now(), seq_ack: lastSeq ?? 0 } })); } catch {} }, p.d.heartbeat_interval);
|
||||
log(`voice gw: HELLO hb=${p.d.heartbeat_interval}`);
|
||||
break;
|
||||
case 2: // READY
|
||||
voiceState.ssrc = p.d.ssrc; voiceState.ip = p.d.ip; voiceState.port = p.d.port;
|
||||
log(`voice gw: READY ssrc=${p.d.ssrc} udp=${p.d.ip}:${p.d.port} modes=${JSON.stringify(p.d.modes)}`);
|
||||
doUdpDiscoveryAndSelect(p.d);
|
||||
break;
|
||||
case 4: // SESSION_DESCRIPTION
|
||||
voiceState.daveVer = p.d?.dave_protocol_version ?? null;
|
||||
log(`voice gw: SESSION_DESCRIPTION dave=${voiceState.daveVer} mode=${p.d?.mode}`);
|
||||
announceReady();
|
||||
break;
|
||||
case 5: // SPEAKING — maps a user to their audio ssrc
|
||||
if (p.d?.user_id && p.d?.ssrc != null) {
|
||||
ssrcToUser.set(p.d.ssrc, p.d.user_id);
|
||||
knownUsers.add(p.d.user_id);
|
||||
log(`voice gw: SPEAKING user=${p.d.user_id} ssrc=${p.d.ssrc} flags=${p.d.speaking}`);
|
||||
}
|
||||
break;
|
||||
case 11: for (const u of (p.d.user_ids || [])) knownUsers.add(u); log(`voice gw: op11 clients_connect ${JSON.stringify(p.d.user_ids)}`); break;
|
||||
case 13: if (p.d?.user_id) { knownUsers.delete(p.d.user_id); log(`voice gw: op13 client_disconnect ${p.d.user_id}`); } break;
|
||||
case 20: if (p.d.user_id) knownUsers.add(p.d.user_id); break;
|
||||
case 21: try { voiceWs.send(JSON.stringify({ op: 23, d: { transition_id: p.d.transition_id } })); } catch {} log('voice gw: DAVE op21 prepare_transition (acked)'); break;
|
||||
case 22: log('voice gw: DAVE op22 execute_transition'); break;
|
||||
default: /* quiet */ break;
|
||||
}
|
||||
}
|
||||
|
||||
function onDaveBinary(op, payload) {
|
||||
try {
|
||||
switch (op) {
|
||||
case 25: {
|
||||
if (!daveSession) daveSession = new davey.DAVESession(voiceState.daveVer || DAVE_VER, SELF_ID, CHANNEL_ID);
|
||||
daveSession.setExternalSender(payload);
|
||||
const kp = daveSession.getSerializedKeyPackage();
|
||||
voiceWs.send(Buffer.concat([Buffer.from([26]), kp]), { binary: true });
|
||||
log(`voice gw: sent DAVE op26 key_package len=${kp.length}`);
|
||||
break;
|
||||
}
|
||||
case 27: {
|
||||
const res = daveSession.processProposals(payload.readUInt8(0), payload.subarray(1), Array.from(knownUsers));
|
||||
if (res && res.commit) {
|
||||
const parts = [Buffer.from([28]), res.commit];
|
||||
if (res.welcome) parts.push(res.welcome);
|
||||
voiceWs.send(Buffer.concat(parts), { binary: true });
|
||||
log(`voice gw: sent DAVE op28 commit_welcome`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 29: daveSession.processCommit(payload.subarray(2)); mlsReady = daveSession.ready; log(`voice gw: op29 commit -> ready=${mlsReady}`); announceReady(); break;
|
||||
case 30: daveSession.processWelcome(payload.subarray(2)); mlsReady = daveSession.ready; log(`voice gw: op30 welcome -> ready=${mlsReady}`); announceReady(); break;
|
||||
default: break;
|
||||
}
|
||||
} catch (e) { log(`voice gw: DAVE op${op} error: ${e.message}`); }
|
||||
}
|
||||
|
||||
let announced = false;
|
||||
function announceReady() {
|
||||
if (announced) return;
|
||||
const daveOk = voiceState.daveVer === 0 || mlsReady;
|
||||
if (voiceState.ssrc != null && voiceState.daveVer != null && daveOk && discoverySelected) {
|
||||
announced = true;
|
||||
log(`✅ JOINED & READY. channel=${CHANNEL_ID} dave=${voiceState.daveVer} mlsReady=${mlsReady} privacyCode=${daveSession?.voicePrivacyCode || 'n/a'}`);
|
||||
log(' staying connected, listening for speakers…');
|
||||
// time-box is owned by the startup hard-ceiling timer (armed regardless of ready state)
|
||||
}
|
||||
}
|
||||
|
||||
function doUdpDiscoveryAndSelect(ready) {
|
||||
udp = dgram.createSocket('udp4');
|
||||
const disc = Buffer.alloc(74);
|
||||
disc.writeUInt16BE(1, 0); disc.writeUInt16BE(70, 2); disc.writeUInt32BE(ready.ssrc, 4);
|
||||
udp.on('message', (msg) => {
|
||||
if (!discoverySelected) {
|
||||
discoverySelected = true;
|
||||
const ipEnd = msg.indexOf(0, 8);
|
||||
const ip = msg.subarray(8, ipEnd).toString();
|
||||
const port = msg.readUInt16BE(msg.length - 2);
|
||||
const mode = (ready.modes || []).includes('aead_aes256_gcm_rtpsize') ? 'aead_aes256_gcm_rtpsize'
|
||||
: (ready.modes || []).includes('aead_xchacha20_poly1305_rtpsize') ? 'aead_xchacha20_poly1305_rtpsize'
|
||||
: (ready.modes || [])[0];
|
||||
voiceState.mode = mode;
|
||||
voiceWs.send(JSON.stringify({ op: 1, d: { protocol: 'udp', data: { address: ip, port, mode }, codecs: [
|
||||
{ name: 'opus', type: 'audio', priority: 1000, payload_type: 120 },
|
||||
] }}));
|
||||
log(`voice gw: sent SELECT PROTOCOL (mode=${mode}) after UDP discovery ${ip}:${port}`);
|
||||
announceReady();
|
||||
return;
|
||||
}
|
||||
// After selection: these are incoming SRTP media packets. Just tally per-SSRC
|
||||
// (decryption -> Opus -> PCM is M2). RTP: ssrc at bytes 8..11, pt = byte1 & 0x7f.
|
||||
if (msg.length >= 12) {
|
||||
const ssrc = msg.readUInt32BE(8);
|
||||
rtpCount.set(ssrc, (rtpCount.get(ssrc) || 0) + 1);
|
||||
const n = rtpCount.get(ssrc);
|
||||
if (n === 1 || n % 200 === 0) {
|
||||
const user = ssrcToUser.get(ssrc) || '?';
|
||||
log(`rtp: ssrc=${ssrc} user=${user} pt=${msg.readUInt8(1) & 0x7f} count=${n}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
udp.on('error', (e) => log('udp error', e.message));
|
||||
udp.send(disc, ready.port, ready.ip, (e) => { if (e) log('udp send err', e.message); else log('udp: sent IP discovery'); });
|
||||
}
|
||||
}
|
||||
1215
dave/package-lock.json
generated
Normal file
1215
dave/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
dave/package.json
Normal file
16
dave/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "wsai-dave-poc",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Discord voice layer for watch_sceen_ai: official bot joins target voice channel (DAVE/MLS E2EE) and receives per-user Opus audio for STT. bot.mjs = current path; gate.mjs/join.mjs = legacy selfbot (kept for deferred video track).",
|
||||
"main": "bot.mjs",
|
||||
"dependencies": {
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@snazzah/davey": "^0.1.12",
|
||||
"discord.js": "^14.27.0",
|
||||
"libsodium-wrappers": "^0.8.4",
|
||||
"prism-media": "^1.3.5",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
24
docker-entrypoint.sh
Normal file
24
docker-entrypoint.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test/run dispatcher for the watch_sceen_ai container.
|
||||
#
|
||||
# smoke (default) pytest smoke suite (mock pipeline, no deps, no GPU)
|
||||
# voice python -m wsai --voice (eyes-free mock voice loop demo)
|
||||
# mock python -m wsai (full mock flow, few frames)
|
||||
# gpu nvidia-smi — prove the GPU is visible inside the container
|
||||
# join node dave/bot.mjs — LIVE official-bot voice join (needs .env
|
||||
# mounted; honors RUN_MS / GUILD_ID / CHANNEL_ID)
|
||||
# shell drop to bash
|
||||
set -euo pipefail
|
||||
cd /app
|
||||
|
||||
cmd="${1:-smoke}"; shift || true
|
||||
|
||||
case "$cmd" in
|
||||
smoke) exec python -m pytest -q ;;
|
||||
voice) exec python -m wsai --voice ;;
|
||||
mock) exec python -m wsai ;;
|
||||
gpu) exec nvidia-smi ;;
|
||||
join) cd dave && exec node bot.mjs "$@" ;;
|
||||
shell) exec bash ;;
|
||||
*) exec "$cmd" "$@" ;;
|
||||
esac
|
||||
75
poc/capture_xvfb.py
Normal file
75
poc/capture_xvfb.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""PoC: prove that a non-headless Chromium under a virtual display (Xvfb) can
|
||||
render a live/animated page and that we can capture it as frames.
|
||||
|
||||
This de-risks the hardest unknown of decision #1 (watch the real Discord screen
|
||||
share by capturing a real client's rendered pixels) BEFORE touching Discord.
|
||||
|
||||
Run under a virtual display:
|
||||
|
||||
xvfb-run -a --server-args="-screen 0 1280x720x24" \
|
||||
.venv/bin/python poc/capture_xvfb.py
|
||||
|
||||
Success = frames are non-blank AND change over time (proving live rendering +
|
||||
capture actually work on this headless server).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
PAGE = (HERE / "test_page.html").resolve()
|
||||
OUT = HERE / "frames"
|
||||
N = 6
|
||||
INTERVAL = 0.4
|
||||
|
||||
|
||||
def main() -> int:
|
||||
OUT.mkdir(exist_ok=True)
|
||||
hashes: list[str] = []
|
||||
sizes: list[int] = []
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
channel="chrome", # use system google-chrome, no browser download
|
||||
headless=False, # WebRTC/video needs a real (virtual) display
|
||||
args=["--no-sandbox", "--disable-gpu", "--autoplay-policy=no-user-gesture-required"],
|
||||
)
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 720})
|
||||
page.goto(PAGE.as_uri())
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
for i in range(N):
|
||||
png = page.screenshot() # captures the rendered virtual screen
|
||||
f = OUT / f"frame_{i:02d}.png"
|
||||
f.write_bytes(png)
|
||||
h = hashlib.sha256(png).hexdigest()[:12]
|
||||
hashes.append(h)
|
||||
sizes.append(len(png))
|
||||
print(f" frame {i}: {len(png):>7} bytes sha={h}")
|
||||
time.sleep(INTERVAL)
|
||||
|
||||
browser.close()
|
||||
|
||||
distinct = len(set(hashes))
|
||||
min_size = min(sizes)
|
||||
print(f"\n{N} frames, {distinct} distinct, min size {min_size} bytes")
|
||||
|
||||
# A blank/black 1280x720 PNG compresses to a few KB; a real rendered scene is
|
||||
# much larger. Require non-trivial size and that the scene actually moved.
|
||||
ok_nonblank = min_size > 8_000
|
||||
ok_changing = distinct >= 3
|
||||
if ok_nonblank and ok_changing:
|
||||
print("PoC PASS: Xvfb + Chromium rendered a live scene and we captured changing, non-blank frames.")
|
||||
return 0
|
||||
print(f"PoC FAIL: nonblank={ok_nonblank} changing={ok_changing}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
29
poc/test_page.html
Normal file
29
poc/test_page.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>capture test</title>
|
||||
<style>html,body{margin:0;background:#101418}</style></head>
|
||||
<body>
|
||||
<canvas id="c" width="1280" height="720"></canvas>
|
||||
<script>
|
||||
// An always-changing scene, so captured frames must differ frame-to-frame and
|
||||
// be non-blank. This stands in for "a Discord screen share is playing".
|
||||
const c = document.getElementById('c'), x = c.getContext('2d');
|
||||
let t = 0;
|
||||
function draw() {
|
||||
t++;
|
||||
x.fillStyle = '#101418'; x.fillRect(0, 0, 1280, 720);
|
||||
// moving box
|
||||
const px = (t * 7) % 1180;
|
||||
x.fillStyle = `hsl(${(t*3)%360} 80% 55%)`;
|
||||
x.fillRect(px, 300 + 120 * Math.sin(t / 15), 100, 100);
|
||||
// big live counter text
|
||||
x.fillStyle = '#e8eef2'; x.font = 'bold 90px monospace';
|
||||
x.fillText('FRAME ' + t, 60, 140);
|
||||
x.font = '28px monospace';
|
||||
x.fillText(new Date().toISOString(), 60, 200);
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
draw();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
requirements.txt
Normal file
20
requirements.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
# Core skeleton has NO required third-party deps (mock mode is pure stdlib).
|
||||
# Install extras per backend you enable:
|
||||
|
||||
# --- screen capture (WSAI_SOURCE=mss) ---
|
||||
# mss
|
||||
# pillow
|
||||
|
||||
# --- cloud eyes + brain (WSAI_VISION=claude / WSAI_BRAIN=claude) ---
|
||||
# anthropic
|
||||
|
||||
# --- voice backends (run in their OWN venvs; loaded as persistent workers) ---
|
||||
# STT: faster-whisper (WSAI_STT=whisper) — installed in a dedicated venv, e.g.
|
||||
# uv venv --python 3.12 /home/claude/jarvis-stt/whisper312
|
||||
# uv pip install --python /home/claude/jarvis-stt/whisper312/bin/python faster-whisper
|
||||
# (override interpreter/model via WSAI_WHISPER_PYTHON / WSAI_WHISPER_MODEL)
|
||||
# TTS: MeloTTS (WSAI_TTS=melo) — in /home/claude/jarvis-tts/melo311
|
||||
# (Opus decode for Discord voice audio, e.g. via the selfbot/ffmpeg path — pending)
|
||||
|
||||
# --- dev ---
|
||||
# pytest
|
||||
90
tests/test_emotion.py
Normal file
90
tests/test_emotion.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Emotion-tag parsing for expressive TTS. A ``[감정]`` tag must steer the
|
||||
pitch/speed of the text that follows without being spoken; a bracket that is NOT
|
||||
a known emotion word must be kept as ordinary spoken content."""
|
||||
|
||||
from wsai.backends.emotion import (
|
||||
EMOTION_PARAMS,
|
||||
match_emotion,
|
||||
parse_segments,
|
||||
)
|
||||
from wsai.dashboard import _speech_text
|
||||
|
||||
BASE = 1.3
|
||||
|
||||
|
||||
def test_emotion_tag_is_not_spoken_and_sets_delivery():
|
||||
segs = parse_segments("[기쁨] 오늘 날씨 좋다", BASE)
|
||||
assert len(segs) == 1
|
||||
assert "기쁨" not in segs[0].text # the tag word is dropped
|
||||
assert segs[0].text == "오늘 날씨 좋다"
|
||||
mult, semis = EMOTION_PARAMS["happy"]
|
||||
assert segs[0].speed == BASE * mult
|
||||
assert segs[0].pitch == semis
|
||||
|
||||
|
||||
def test_midreply_emotion_change_splits_segments():
|
||||
segs = parse_segments("[속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!", BASE)
|
||||
assert len(segs) == 2
|
||||
assert segs[0].text == "정말 힘들었겠다."
|
||||
assert segs[1].text == "하지만 넌 할 수 있어!"
|
||||
assert segs[0].pitch == EMOTION_PARAMS["sad"][1]
|
||||
assert segs[1].pitch == EMOTION_PARAMS["hopeful"][1]
|
||||
|
||||
|
||||
def test_non_emotion_bracket_is_spoken_without_brackets():
|
||||
segs = parse_segments("[기쁨] 첫째는 [1번] 항목이야", BASE)
|
||||
assert len(segs) == 1
|
||||
# "1번" is not an emotion -> read it; brackets themselves are gone.
|
||||
assert "1번" in segs[0].text
|
||||
assert "[" not in segs[0].text and "]" not in segs[0].text
|
||||
assert segs[0].pitch == EMOTION_PARAMS["happy"][1]
|
||||
|
||||
|
||||
def test_text_before_first_tag_is_neutral():
|
||||
segs = parse_segments("잠깐만. [신남] 찾았다!", BASE)
|
||||
assert segs[0].text == "잠깐만."
|
||||
assert segs[0].speed == BASE and segs[0].pitch == 0.0
|
||||
assert segs[1].pitch == EMOTION_PARAMS["excited"][1]
|
||||
|
||||
|
||||
def test_plain_text_is_one_neutral_segment():
|
||||
segs = parse_segments("그냥 평범한 문장이야", BASE)
|
||||
assert len(segs) == 1
|
||||
assert segs[0].speed == BASE and segs[0].pitch == 0.0
|
||||
|
||||
|
||||
def test_empty_input_yields_no_segments():
|
||||
assert parse_segments("", BASE) == []
|
||||
assert parse_segments(" ", BASE) == []
|
||||
|
||||
|
||||
def test_only_emotion_tags_yield_no_segments():
|
||||
# A reply that is nothing but tags has nothing to say.
|
||||
assert parse_segments("[기쁨][신남]", BASE) == []
|
||||
|
||||
|
||||
def test_match_emotion_is_synonym_and_space_tolerant():
|
||||
assert match_emotion("속상함") == "sad"
|
||||
assert match_emotion(" 힘 차게 ") == "hopeful" # squeezed + trimmed
|
||||
assert match_emotion("행복하게") == "happy"
|
||||
assert match_emotion("메모") is None # not an emotion
|
||||
|
||||
|
||||
def test_persona_examples_are_all_recognised():
|
||||
# Every emotion the brain persona advertises must resolve, or it would be
|
||||
# read aloud instead of shaping the voice.
|
||||
for word in ["힘차게", "궁금", "반가움", "차분하게", "웃으며", "속상함"]:
|
||||
assert match_emotion(word) is not None, word
|
||||
|
||||
|
||||
def test_voice_turn_keeps_leading_emotion_tag_for_tts_parser():
|
||||
text = "[속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!"
|
||||
|
||||
spoken = _speech_text(text)
|
||||
segs = parse_segments(spoken, BASE)
|
||||
|
||||
assert spoken == text
|
||||
assert segs[0].text == "정말 힘들었겠다."
|
||||
assert segs[0].pitch == EMOTION_PARAMS["sad"][1]
|
||||
assert segs[1].text == "하지만 넌 할 수 있어!"
|
||||
assert segs[1].pitch == EMOTION_PARAMS["hopeful"][1]
|
||||
119
tests/test_monitor.py
Normal file
119
tests/test_monitor.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""The monitor must record step-by-step turns (heard / thought / answered,
|
||||
per-step timing, ok vs error) and stream them to subscribers — that data is
|
||||
exactly what the status dashboard renders."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from wsai.backends.mock import MockBrain, MockSTT, MockTTS
|
||||
from wsai.monitor import Monitor
|
||||
from wsai.pipeline import Pipeline
|
||||
|
||||
|
||||
def test_monitor_records_turn_with_timed_steps():
|
||||
async def go():
|
||||
mon = Monitor()
|
||||
|
||||
pipe = Pipeline(
|
||||
brain=MockBrain(),
|
||||
stt=MockSTT(script=["안녕"], interval=0.01),
|
||||
tts=MockTTS(),
|
||||
monitor=mon,
|
||||
)
|
||||
await asyncio.wait_for(pipe.run(), timeout=5)
|
||||
return mon
|
||||
|
||||
mon = asyncio.run(go())
|
||||
snap = mon.snapshot()
|
||||
|
||||
assert snap["status"]["turns_total"] == 1
|
||||
assert snap["status"]["running"] is False # cleaned up after run
|
||||
assert len(snap["turns"]) == 1
|
||||
|
||||
turn = snap["turns"][0]
|
||||
assert turn["heard"] == "안녕" # what it heard
|
||||
assert turn["reply"] # what it answered
|
||||
assert turn["status"] == "ok" # it worked
|
||||
assert turn["total_ms"] >= 0
|
||||
# step-by-step: every stage is named and timed
|
||||
names = [s["name"] for s in turn["steps"]]
|
||||
assert names == ["화면 맥락", "두뇌(생각)", "응답(TTS/전송)"]
|
||||
assert all(s["ok"] is True for s in turn["steps"])
|
||||
assert all(s["ms"] >= 0 for s in turn["steps"])
|
||||
|
||||
|
||||
def test_monitor_marks_errors():
|
||||
class BoomBrain(MockBrain):
|
||||
async def respond(self, user_text, screen, history):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
async def go():
|
||||
mon = Monitor()
|
||||
pipe = Pipeline(
|
||||
brain=BoomBrain(),
|
||||
stt=MockSTT(script=["안녕"], interval=0.01),
|
||||
tts=MockTTS(),
|
||||
monitor=mon,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(pipe.run(), timeout=5)
|
||||
except BaseException:
|
||||
pass # TaskGroup re-raises; we only care about recorded telemetry
|
||||
return mon
|
||||
|
||||
mon = asyncio.run(go())
|
||||
snap = mon.snapshot()
|
||||
|
||||
turn = snap["turns"][0]
|
||||
assert turn["status"] == "error"
|
||||
brain_step = next(s for s in turn["steps"] if s["name"] == "두뇌(생각)")
|
||||
assert brain_step["ok"] is False
|
||||
assert "boom" in brain_step["error"]
|
||||
assert snap["status"]["errors_total"] >= 1
|
||||
|
||||
|
||||
def test_error_turn_increments_errors_total_once():
|
||||
"""The dashboard voice turn (dashboard.voice_turn) records a turn and calls
|
||||
turn.finish(error=...) directly — it never goes through the pipeline's
|
||||
log("error") path. That failure must still land in errors_total, and exactly
|
||||
once no matter how many times the turn is re-published."""
|
||||
mon = Monitor()
|
||||
turn = mon.turn(source="discord")
|
||||
turn.heard("`코드` 얘기") # touches/publishes again
|
||||
turn.replied("답변") # and again
|
||||
assert mon.status_snapshot()["errors_total"] == 0
|
||||
|
||||
turn.finish(error="melo synth failed: KeyError '`'")
|
||||
assert mon.status_snapshot()["errors_total"] == 1
|
||||
|
||||
# A stray re-finish/re-publish must not double count.
|
||||
turn.finish(error="melo synth failed: KeyError '`'")
|
||||
turn._touch()
|
||||
assert mon.status_snapshot()["errors_total"] == 1
|
||||
|
||||
# The failure is also visible as an error-level event for the live feed.
|
||||
events = mon.snapshot()["events"]
|
||||
assert any(e["type"] == "log" and e["level"] == "error" for e in events)
|
||||
|
||||
|
||||
def test_subscriber_receives_live_turn_events():
|
||||
async def go():
|
||||
mon = Monitor()
|
||||
q = mon.subscribe()
|
||||
pipe = Pipeline(
|
||||
brain=MockBrain(),
|
||||
stt=MockSTT(script=["안녕"], interval=0.01),
|
||||
tts=MockTTS(),
|
||||
monitor=mon,
|
||||
)
|
||||
await asyncio.wait_for(pipe.run(), timeout=5)
|
||||
return q
|
||||
|
||||
q = asyncio.run(go())
|
||||
events = []
|
||||
while not q.empty():
|
||||
events.append(json.loads(q.get_nowait()))
|
||||
|
||||
types = {e["type"] for e in events}
|
||||
assert "turn" in types # live turn updates were pushed
|
||||
assert "status" in types # listening/running status changes were pushed
|
||||
136
tests/test_pipeline.py
Normal file
136
tests/test_pipeline.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Smoke test: the mock pipeline must run end-to-end and route screen context
|
||||
into the brain's replies."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from wsai.backends.mock import (
|
||||
MockBrain,
|
||||
MockFrameSource,
|
||||
MockSTT,
|
||||
MockTTS,
|
||||
MockVision,
|
||||
)
|
||||
from wsai.interfaces import Frame
|
||||
from wsai.pipeline import Pipeline
|
||||
|
||||
|
||||
def test_mock_pipeline_runs_and_replies(capsys):
|
||||
replies: list[str] = []
|
||||
|
||||
class CapturingTTS(MockTTS):
|
||||
async def speak(self, reply):
|
||||
replies.append(reply.text)
|
||||
|
||||
pipe = Pipeline(
|
||||
source=MockFrameSource(interval=0.05, limit=3),
|
||||
vision=MockVision(),
|
||||
brain=MockBrain(),
|
||||
stt=MockSTT(script=["화면에 뭐 보여?"], interval=0.1),
|
||||
tts=CapturingTTS(),
|
||||
)
|
||||
|
||||
asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))
|
||||
|
||||
assert replies, "brain produced no reply"
|
||||
# The reply must embed the screen observation → context reached the brain.
|
||||
assert "화면:" in replies[0]
|
||||
|
||||
|
||||
def test_voice_only_pipeline_runs_without_eyes():
|
||||
"""Eyes-free config (no source/vision) still runs STT -> Brain -> TTS."""
|
||||
replies: list[str] = []
|
||||
|
||||
class CapturingTTS(MockTTS):
|
||||
async def speak(self, reply):
|
||||
replies.append(reply.text)
|
||||
|
||||
pipe = Pipeline(
|
||||
brain=MockBrain(),
|
||||
stt=MockSTT(script=["안녕", "잘 있어"], interval=0.05),
|
||||
tts=CapturingTTS(),
|
||||
)
|
||||
|
||||
asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))
|
||||
|
||||
assert len(replies) == 2, "voice loop did not reply to every utterance"
|
||||
# No eyes → the brain must report it has not seen a screen.
|
||||
assert "아직 화면을 못 읽었어요" in replies[0]
|
||||
|
||||
|
||||
def test_error_in_one_loop_cancels_siblings_and_closes():
|
||||
"""If the conversation loop raises, the perception loop must be cancelled
|
||||
(not left running detached) and every source must still be closed — i.e. no
|
||||
close-during-use and no orphaned task."""
|
||||
closed = {"source": False, "stt": False}
|
||||
|
||||
class ForeverSource:
|
||||
async def frames(self):
|
||||
while True:
|
||||
await asyncio.sleep(0.01)
|
||||
yield Frame(data=b"", width=1, height=1, ts=0.0)
|
||||
|
||||
async def aclose(self):
|
||||
closed["source"] = True
|
||||
|
||||
class BoomSTT(MockSTT):
|
||||
async def aclose(self):
|
||||
closed["stt"] = True
|
||||
|
||||
class BoomBrain(MockBrain):
|
||||
async def respond(self, user_text, screen, history):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
pipe = Pipeline(
|
||||
source=ForeverSource(),
|
||||
vision=MockVision(),
|
||||
brain=BoomBrain(),
|
||||
stt=BoomSTT(script=["hi"], interval=0.01),
|
||||
tts=MockTTS(),
|
||||
)
|
||||
|
||||
with pytest.raises(BaseException): # TaskGroup raises an ExceptionGroup
|
||||
asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))
|
||||
|
||||
assert closed["source"] is True, "perception source was not closed (orphaned loop)"
|
||||
assert closed["stt"] is True, "stt was not closed"
|
||||
|
||||
|
||||
def test_history_is_bounded():
|
||||
pipe = Pipeline(
|
||||
source=MockFrameSource(limit=0),
|
||||
vision=MockVision(),
|
||||
brain=MockBrain(),
|
||||
history_turns=3,
|
||||
)
|
||||
for i in range(10):
|
||||
pipe._remember(f"u{i}", f"a{i}")
|
||||
assert len(pipe._history) == 3
|
||||
assert pipe._history[-1] == ("u9", "a9")
|
||||
|
||||
|
||||
def test_prewarm_warms_backends_and_survives_failure():
|
||||
"""Backends exposing warmup() are preloaded at startup; a warmup that
|
||||
raises is logged but must not abort the run (first-utterance latency is an
|
||||
optimization, not a hard requirement)."""
|
||||
warmed: list[str] = []
|
||||
|
||||
class WarmTTS(MockTTS):
|
||||
async def warmup(self):
|
||||
warmed.append("tts")
|
||||
|
||||
class BoomWarmSTT(MockSTT):
|
||||
async def warmup(self):
|
||||
warmed.append("stt")
|
||||
raise RuntimeError("model unavailable")
|
||||
|
||||
pipe = Pipeline(
|
||||
brain=MockBrain(),
|
||||
stt=BoomWarmSTT(script=["안녕"], interval=0.01),
|
||||
tts=WarmTTS(),
|
||||
)
|
||||
|
||||
asyncio.run(asyncio.wait_for(pipe.run(), timeout=5))
|
||||
|
||||
assert "tts" in warmed and "stt" in warmed, "warmup() not called on backends"
|
||||
39
tests/test_textnorm.py
Normal file
39
tests/test_textnorm.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""TTS input normalisation. Claude speaks in markdown/backticks; MeloTTS's
|
||||
Korean normaliser crashes on a bare backtick (``KeyError: '`'``), which used to
|
||||
take down the whole voice turn. normalize_for_speech() must strip formatting and
|
||||
above all guarantee no backtick reaches the synthesiser."""
|
||||
|
||||
from wsai.backends.melo import normalize_for_speech
|
||||
|
||||
|
||||
def test_backticks_are_always_removed():
|
||||
# The exact crash trigger: inline code, a fenced block, and a stray backtick.
|
||||
reply = "`ls -la` 를 써봐. 예시:\n```python\nprint('hi')\n```\n그리고 ` 이건 홀로 남은 백틱"
|
||||
out = normalize_for_speech(reply)
|
||||
assert "`" not in out # the character that crashes MeloTTS is gone
|
||||
assert "ls -la" in out # inner words are kept, just unwrapped
|
||||
assert "print('hi')" in out # fenced code content survives as spoken text
|
||||
|
||||
|
||||
def test_markdown_structure_flattened():
|
||||
reply = "# 제목\n- 첫째 항목\n- 둘째 항목\n**굵게** 그리고 _기울임_\n> 인용문"
|
||||
out = normalize_for_speech(reply)
|
||||
assert "#" not in out
|
||||
assert "**" not in out and "_" not in out
|
||||
assert not out.lstrip().startswith(("-", ">"))
|
||||
assert "첫째 항목" in out and "굵게" in out and "인용문" in out
|
||||
|
||||
|
||||
def test_links_reduced_to_label():
|
||||
out = normalize_for_speech("자세히는 [문서](https://example.com/docs) 참고해")
|
||||
assert "문서" in out
|
||||
assert "http" not in out and "]" not in out and "(" not in out
|
||||
|
||||
|
||||
def test_plain_text_is_left_intact():
|
||||
plain = "안녕, 지금 화면 잘 보고 있어. 뭐 도와줄까?"
|
||||
assert normalize_for_speech(plain) == plain
|
||||
|
||||
|
||||
def test_empty_is_safe():
|
||||
assert normalize_for_speech("") == ""
|
||||
60
tests/test_whisper_stt.py
Normal file
60
tests/test_whisper_stt.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Unit tests for the WhisperSTT source plumbing.
|
||||
|
||||
These do NOT load a model or spawn the worker (that needs the whisper312 venv and
|
||||
is exercised by the manual TTS->STT round trip). They pin the SpeechToText
|
||||
contract: how `utterances()` turns an audio source into Utterances, and how it
|
||||
behaves with no source wired yet.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import AsyncIterator
|
||||
|
||||
from wsai.backends.whisper import WhisperSTT
|
||||
from wsai.interfaces import SpeechToText, Utterance
|
||||
|
||||
|
||||
def _collect(stt: WhisperSTT) -> list[Utterance]:
|
||||
async def run():
|
||||
return [u async for u in stt.utterances()]
|
||||
|
||||
return asyncio.run(asyncio.wait_for(run(), timeout=5))
|
||||
|
||||
|
||||
async def _paths(items) -> AsyncIterator[str]:
|
||||
for it in items:
|
||||
yield it
|
||||
|
||||
|
||||
def test_is_speech_to_text():
|
||||
assert isinstance(WhisperSTT(), SpeechToText)
|
||||
|
||||
|
||||
def test_no_audio_source_yields_nothing():
|
||||
# Discord voice receiver not wired yet -> the loop idles and returns.
|
||||
assert _collect(WhisperSTT(audio_source=None)) == []
|
||||
|
||||
|
||||
def test_utterances_transcribes_each_chunk(monkeypatch):
|
||||
stt = WhisperSTT(audio_source=_paths(["a.wav", "b.wav"]))
|
||||
|
||||
async def fake_transcribe(wav_path, *, language=None):
|
||||
return f"text::{wav_path}"
|
||||
|
||||
monkeypatch.setattr(stt, "transcribe", fake_transcribe)
|
||||
|
||||
utts = _collect(stt)
|
||||
assert [u.text for u in utts] == ["text::a.wav", "text::b.wav"]
|
||||
assert all(u.source == "voice" for u in utts)
|
||||
|
||||
|
||||
def test_empty_transcript_is_skipped(monkeypatch):
|
||||
# Silence / VAD-filtered audio transcribes to "" and must not become a turn.
|
||||
stt = WhisperSTT(audio_source=_paths(["silent.wav", "real.wav"]))
|
||||
|
||||
async def fake_transcribe(wav_path, *, language=None):
|
||||
return "" if wav_path == "silent.wav" else "안녕"
|
||||
|
||||
monkeypatch.setattr(stt, "transcribe", fake_transcribe)
|
||||
|
||||
utts = _collect(stt)
|
||||
assert [u.text for u in utts] == ["안녕"]
|
||||
8
wsai/__init__.py
Normal file
8
wsai/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""watch_screen_ai — an AI that watches a shared screen and talks with you."""
|
||||
|
||||
from .config import Settings
|
||||
from .factory import build
|
||||
from .pipeline import Pipeline
|
||||
|
||||
__all__ = ["Settings", "build", "Pipeline"]
|
||||
__version__ = "0.0.1"
|
||||
232
wsai/__main__.py
Normal file
232
wsai/__main__.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Entry point.
|
||||
|
||||
python -m wsai # mock pipeline (no deps, no keys) — runs a demo
|
||||
python -m wsai --voice # eyes-free voice loop demo (STT -> Brain -> TTS)
|
||||
python -m wsai --dashboard # live status website + a short voice demo, then idle
|
||||
python -m wsai --live # capture this screen + Claude eyes/brain
|
||||
python -m wsai --env # build from WSAI_* environment variables
|
||||
|
||||
The mock run is bounded (a few frames + a scripted conversation) so it exits on
|
||||
its own; --dashboard keeps the site open after a short demo; --live/--env run
|
||||
until Ctrl-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
|
||||
from .config import Settings
|
||||
from .factory import build
|
||||
from .monitor import Monitor
|
||||
|
||||
|
||||
def _lan_ip() -> str:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except OSError:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
async def _run(
|
||||
settings: Settings,
|
||||
demo: bool,
|
||||
monitor: Monitor | None,
|
||||
*,
|
||||
demo_loop: bool = False,
|
||||
keep_dashboard_open: bool = False,
|
||||
) -> None:
|
||||
if demo:
|
||||
# Bounded demo so CI / a quick check terminates.
|
||||
from .backends.mock import MockFrameSource, MockSTT
|
||||
|
||||
pipe = build(settings, monitor=monitor)
|
||||
if pipe.source is not None: # keep eyes-free configs eyes-free
|
||||
pipe.source = MockFrameSource(interval=0.3, limit=4)
|
||||
if pipe.stt is not None:
|
||||
pipe.stt = MockSTT(interval=2.0 if demo_loop else 0.4, loop=demo_loop)
|
||||
await pipe.run()
|
||||
if keep_dashboard_open:
|
||||
# Keep the status page alive without generating fake conversations
|
||||
# forever. The previous default looped mock STT indefinitely, which
|
||||
# made the dashboard look like it had heard thousands of real users.
|
||||
if monitor is not None:
|
||||
monitor.set_status(running=True, listening=False)
|
||||
monitor.log("info", "mock 데모 완료 — 실제 음성 파이프라인 연결 대기")
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
await build(settings, monitor=monitor).run()
|
||||
|
||||
|
||||
def _run_stt_test(host: str, port: int) -> None:
|
||||
"""Serve the dashboard with a real GPU STT backend so a human can test
|
||||
recognition from the browser (record mic or upload an audio file). No mock
|
||||
conversation loop — the page just hosts the recognition test."""
|
||||
import time
|
||||
|
||||
from .backends.whisper import WhisperSTT
|
||||
from .dashboard import Dashboard
|
||||
from .monitor import Monitor
|
||||
|
||||
monitor = Monitor()
|
||||
stt = WhisperSTT()
|
||||
dash = Dashboard(monitor, host=host, port=port, stt=stt)
|
||||
dash.start()
|
||||
monitor.set_components({"source": "none", "vision": "none", "stt": "whisper",
|
||||
"brain": "none", "tts": "none"})
|
||||
monitor.set_status(running=True, listening=False)
|
||||
monitor.log("info", "STT 인식 테스트 서버 시작 — GPU 워밍업 중…")
|
||||
print("\n STT 워밍업 중… (모델 로드 + CUDA 예열)")
|
||||
dash.warm() # load + warm the GPU worker so the first recognition is instant
|
||||
dev = getattr(stt, "resolved_device", None) or "?"
|
||||
monitor.log("info", f"STT 준비 완료 (device={dev}). 녹음/파일 업로드로 인식하세요.")
|
||||
|
||||
shown = host if host not in ("0.0.0.0", "") else _lan_ip()
|
||||
print(f"\n 음성 인식 테스트 사이트: http://{shown}:{port} (STT device: {dev})")
|
||||
print(f" (로컬: http://127.0.0.1:{port} )\n")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
dash.stop()
|
||||
|
||||
|
||||
def _run_voice_server(host: str, port: int) -> None:
|
||||
"""Serve the STT+TTS voice-turn endpoint that the Discord bot (dave/bot.mjs)
|
||||
calls: it POSTs a captured utterance wav and gets back the reply wav to play
|
||||
into the voice channel. Both STT and TTS run on the GPU and are pre-warmed.
|
||||
The same page also shows the live turn feed. Echo mode for now (the reply is
|
||||
what was heard); the Claude brain can be added as the next slice."""
|
||||
import time
|
||||
|
||||
from .backends.melo import MeloTTS
|
||||
from .backends.whisper import WhisperSTT
|
||||
from .dashboard import Dashboard
|
||||
from .monitor import Monitor
|
||||
|
||||
# Real Claude brain (think + reply). If it can't be constructed (no anthropic
|
||||
# package / no Claude auth), fall back to echo so the loop still works.
|
||||
brain = None
|
||||
brain_name = "echo"
|
||||
if os.environ.get("WSAI_BRAIN", "claude").lower() not in ("none", "echo"):
|
||||
try:
|
||||
from .backends.claude import ClaudeBrain
|
||||
model = os.environ.get("WSAI_BRAIN_MODEL", "claude-sonnet-4-5")
|
||||
brain = ClaudeBrain(model=model)
|
||||
brain_name = "claude"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.getLogger("wsai").warning("brain disabled (echo fallback): %s", exc)
|
||||
|
||||
monitor = Monitor()
|
||||
stt = WhisperSTT()
|
||||
tts = MeloTTS()
|
||||
dash = Dashboard(monitor, host=host, port=port, stt=stt, tts=tts, brain=brain)
|
||||
dash.start()
|
||||
monitor.set_components({"source": "none", "vision": "none", "stt": "whisper",
|
||||
"brain": brain_name, "tts": "melo"})
|
||||
monitor.set_status(running=True, listening=False)
|
||||
monitor.log("info", "디스코드 음성 서버 시작 — STT+TTS GPU 워밍업 중…")
|
||||
print("\n STT+TTS 워밍업 중… (모델 로드 + CUDA 예열)")
|
||||
dash.warm()
|
||||
sdev = getattr(stt, "resolved_device", None) or "?"
|
||||
monitor.set_status(listening=True)
|
||||
monitor.log("info", f"음성 서버 준비 완료 (STT device={sdev}). 디스코드 봇 연결 대기.")
|
||||
|
||||
shown = host if host not in ("0.0.0.0", "") else _lan_ip()
|
||||
print(f"\n 음성 서버 준비 완료 (STT device: {sdev}, 두뇌: {brain_name})")
|
||||
print(f" 대시보드/상태: http://{shown}:{port}")
|
||||
print(f" 봇 연결 엔드포인트: http://127.0.0.1:{port}/api/voice-turn\n")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
dash.stop()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(prog="wsai")
|
||||
ap.add_argument("--voice", action="store_true", help="eyes-free voice loop (STT -> Brain -> TTS)")
|
||||
ap.add_argument("--dashboard", action="store_true", help="serve the live status website")
|
||||
ap.add_argument("--dashboard-loop-demo", action="store_true",
|
||||
help="keep generating mock demo utterances forever (off by default)")
|
||||
ap.add_argument("--stt-test", action="store_true",
|
||||
help="serve the dashboard with a live GPU STT recognition test")
|
||||
ap.add_argument("--voice-server", action="store_true",
|
||||
help="serve STT+TTS voice-turn endpoint for the Discord bot (dave/bot.mjs)")
|
||||
ap.add_argument("--live", action="store_true", help="capture screen + Claude backends")
|
||||
ap.add_argument("--env", action="store_true", help="build from WSAI_* env vars")
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("WSAI_DASHBOARD_PORT", "8787")),
|
||||
help="dashboard port (default 8787, or WSAI_DASHBOARD_PORT)")
|
||||
ap.add_argument("--host", default=os.environ.get("WSAI_DASHBOARD_HOST", "0.0.0.0"),
|
||||
help="dashboard bind host (default 0.0.0.0)")
|
||||
ap.add_argument("-v", "--verbose", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
if args.stt_test:
|
||||
_run_stt_test(args.host, args.port)
|
||||
return
|
||||
|
||||
if args.voice_server:
|
||||
_run_voice_server(args.host, args.port)
|
||||
return
|
||||
|
||||
if args.dashboard:
|
||||
# Default to the eyes-free voice preset for the demo; env can override.
|
||||
settings = Settings.from_env() if args.env else Settings.voice()
|
||||
demo = not args.env
|
||||
elif args.voice:
|
||||
settings, demo = Settings.voice(), True
|
||||
elif args.live:
|
||||
settings, demo = Settings.live(), False
|
||||
elif args.env:
|
||||
settings, demo = Settings.from_env(), False
|
||||
else:
|
||||
settings, demo = Settings.mock(), True
|
||||
|
||||
monitor: Monitor | None = None
|
||||
dash = None
|
||||
if args.dashboard:
|
||||
from .dashboard import Dashboard
|
||||
|
||||
monitor = Monitor()
|
||||
dash = Dashboard(monitor, host=args.host, port=args.port)
|
||||
dash.start()
|
||||
shown = args.host if args.host not in ("0.0.0.0", "") else _lan_ip()
|
||||
print(f"\n 실시간 상태 사이트: http://{shown}:{args.port}")
|
||||
print(f" (로컬: http://127.0.0.1:{args.port} )\n")
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
_run(
|
||||
settings,
|
||||
demo,
|
||||
monitor,
|
||||
demo_loop=args.dashboard and args.dashboard_loop_demo,
|
||||
keep_dashboard_open=args.dashboard and demo and not args.dashboard_loop_demo,
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if dash is not None:
|
||||
dash.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
wsai/backends/__init__.py
Normal file
0
wsai/backends/__init__.py
Normal file
67
wsai/backends/capture_mss.py
Normal file
67
wsai/backends/capture_mss.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Local screen capture via `mss`.
|
||||
|
||||
This is the practical "eye": run this on the machine that is in the Discord call
|
||||
viewing the shared screen, and it captures that monitor/region. Swap in a
|
||||
discord-web capture later without touching the pipeline.
|
||||
|
||||
Requires: pip install mss pillow
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import time
|
||||
from typing import AsyncIterator
|
||||
|
||||
from ..interfaces import Frame
|
||||
|
||||
|
||||
class MSSFrameSource:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
monitor: int = 1,
|
||||
region: dict | None = None,
|
||||
interval: float = 1.5,
|
||||
max_width: int = 1280,
|
||||
) -> None:
|
||||
# `region` overrides `monitor`: {"top":.., "left":.., "width":.., "height":..}
|
||||
self.monitor = monitor
|
||||
self.region = region
|
||||
self.interval = interval
|
||||
self.max_width = max_width
|
||||
self._sct = None
|
||||
|
||||
def _ensure(self):
|
||||
if self._sct is None:
|
||||
import mss # lazy import so mock mode needs no dependency
|
||||
|
||||
self._sct = mss.mss()
|
||||
return self._sct
|
||||
|
||||
def _grab_png(self) -> tuple[bytes, int, int]:
|
||||
from PIL import Image
|
||||
|
||||
sct = self._ensure()
|
||||
area = self.region or sct.monitors[self.monitor]
|
||||
shot = sct.grab(area)
|
||||
img = Image.frombytes("RGB", shot.size, shot.rgb)
|
||||
if img.width > self.max_width:
|
||||
h = int(img.height * self.max_width / img.width)
|
||||
img = img.resize((self.max_width, h))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue(), img.width, img.height
|
||||
|
||||
async def frames(self) -> AsyncIterator[Frame]:
|
||||
while True:
|
||||
# mss is blocking; keep the event loop free.
|
||||
data, w, h = await asyncio.to_thread(self._grab_png)
|
||||
yield Frame(data=data, width=w, height=h, ts=time.monotonic(), mime="image/png")
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._sct is not None:
|
||||
self._sct.close()
|
||||
self._sct = None
|
||||
184
wsai/backends/claude.py
Normal file
184
wsai/backends/claude.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Cloud brain + vision via the Anthropic (Claude) API.
|
||||
|
||||
Both share one auth resolver. Vision sends the frame as a base64 image; the
|
||||
brain is a plain chat call that receives the latest screen description as
|
||||
context.
|
||||
|
||||
Auth (two ways, tried in this order):
|
||||
1. ANTHROPIC_API_KEY -> standard API-key auth.
|
||||
2. A Claude Code OAuth token (this deployment's Max login) read from the
|
||||
credentials file at $CLAUDE_CREDENTIALS_PATH. OAuth tokens require the
|
||||
first system block to be exactly the Claude Code identity string and are
|
||||
sent as a Bearer token (auth_token=), not x-api-key. The token is
|
||||
re-read before each request so a refresh rotated into the file by the
|
||||
host is picked up without a restart.
|
||||
|
||||
Requires: pip install anthropic
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..interfaces import Frame, Reply, ScreenObservation
|
||||
from ..prompt_store import get_persona
|
||||
|
||||
# Claude Code OAuth tokens only answer when the first system block is exactly
|
||||
# this identity string; the real persona/instructions go in later blocks.
|
||||
_CLAUDE_CODE_ID = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
|
||||
# Claude occasionally returns 529 Overloaded; the anthropic SDK retries >=500
|
||||
# (and 429) with exponential backoff, but its default of 2 tries can be too few
|
||||
# to ride out a busy window. Bump it so a transient overload doesn't drop the
|
||||
# voice turn to the apology fallback. Kept modest so a *sustained* overload
|
||||
# still fails fast rather than leaving the bot silent for many seconds.
|
||||
_MAX_RETRIES = int(os.environ.get("WSAI_BRAIN_MAX_RETRIES", "4"))
|
||||
|
||||
|
||||
def _load_oauth_token() -> str | None:
|
||||
path = os.environ.get("CLAUDE_CREDENTIALS_PATH")
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)["claudeAiOauth"]["accessToken"]
|
||||
except (OSError, KeyError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class _Auth:
|
||||
"""Resolves a Claude client, preferring an explicit/env API key and falling
|
||||
back to the deployment's OAuth token. The OAuth client is rebuilt whenever
|
||||
the token in the credentials file changes (host-side refresh)."""
|
||||
|
||||
def __init__(self, api_key: str | None = None) -> None:
|
||||
import anthropic # lazy so mock mode needs no dependency
|
||||
|
||||
self._anthropic = anthropic
|
||||
self._explicit_key = api_key
|
||||
self._client = None
|
||||
self._token: str | None = None
|
||||
self._oauth = False
|
||||
|
||||
def client(self):
|
||||
key = self._explicit_key or os.environ.get("ANTHROPIC_API_KEY")
|
||||
if key:
|
||||
if self._client is None:
|
||||
self._client = self._anthropic.AsyncAnthropic(api_key=key, max_retries=_MAX_RETRIES)
|
||||
self._oauth = False
|
||||
return self._client
|
||||
tok = _load_oauth_token()
|
||||
if not tok:
|
||||
raise RuntimeError(
|
||||
"No Claude auth: set ANTHROPIC_API_KEY or provide a Claude "
|
||||
"OAuth login via CLAUDE_CREDENTIALS_PATH."
|
||||
)
|
||||
if tok != self._token:
|
||||
self._token = tok
|
||||
self._client = self._anthropic.AsyncAnthropic(auth_token=tok, max_retries=_MAX_RETRIES)
|
||||
self._oauth = True
|
||||
return self._client
|
||||
|
||||
def system(self, *blocks: str) -> list[dict]:
|
||||
"""Build the system prompt, prepending the Claude Code identity block
|
||||
when authing via OAuth (required) — harmless to include either way."""
|
||||
texts = [_CLAUDE_CODE_ID, *blocks] if self._oauth else list(blocks)
|
||||
return [{"type": "text", "text": t} for t in texts if t]
|
||||
|
||||
|
||||
class ClaudeVision:
|
||||
def __init__(self, *, model: str = "claude-sonnet-4-5", api_key: str | None = None) -> None:
|
||||
self.model = model
|
||||
self._auth = _Auth(api_key)
|
||||
|
||||
async def describe(self, frame: Frame, hint: str | None = None) -> ScreenObservation:
|
||||
prompt = hint or (
|
||||
"이건 디스코드 화면공유 캡처야. 지금 화면에서 무슨 일이 벌어지는지 "
|
||||
"2~3문장으로 한국어로 간결하게 설명해줘. 코드/에러/게임/문서 등 맥락을 짚어줘."
|
||||
)
|
||||
b64 = base64.b64encode(frame.data).decode()
|
||||
client = self._auth.client()
|
||||
resp = await client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=300,
|
||||
system=self._auth.system(),
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "media_type": frame.mime, "data": b64},
|
||||
},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
text = "".join(b.text for b in resp.content if b.type == "text")
|
||||
return ScreenObservation(text=text.strip(), ts=frame.ts)
|
||||
|
||||
|
||||
class ClaudeBrain:
|
||||
PERSONA = (
|
||||
"너는 디스코드를 이용해 사용자와 실시간으로 대화하는 AI 인공지능이야.\n\n"
|
||||
"1. 역할\n"
|
||||
"- 사용자의 말을 듣고 자연스럽게 대답한다.\n"
|
||||
"- 음성 대화에 어울리게 짧고 빠르게 반응한다.\n"
|
||||
"- 친구처럼 편하게, 무례하거나 과하게 장난치진 않는다.\n\n"
|
||||
"2. 언어\n"
|
||||
"- \"영어로 해줘\"처럼 특정 언어를 요청하지 않으면 무조건 한국어로 답한다.\n"
|
||||
"- 사용자가 다른 언어로 말해도 언어 변경 요청이 없으면 한국어로 답한다.\n\n"
|
||||
"3. 답변 방식\n"
|
||||
"- 음성으로 읽히니 마크다운·코드블록·특수기호·목록기호·이모지 없이 평범한 말로만 답한다.\n"
|
||||
"- 기본은 한두 문장, 길어도 10초 안팎. 길어질 땐 핵심부터 말하고 필요하면 이어서 설명한다.\n"
|
||||
"- URL·긴 숫자·시간·단위·코드는 소리내 읽기 좋게 풀어서 말한다.\n\n"
|
||||
"4. 대화 태도\n"
|
||||
"- 사용자의 말투·분위기에 맞춰 반응한다.\n"
|
||||
"- 모르면 지어내지 말고 모른다고 하고, 애매하면 되묻는다(\"다시 말해줄래?\").\n"
|
||||
"- 잡음·침묵·의미 없는 소리엔 억지로 대답하지 않는다.\n\n"
|
||||
"5. 안전·사실성\n"
|
||||
"- 위험하거나 불법적인 요청은 돕지 않는다.\n"
|
||||
"- 확인되지 않은 사실이나 최신 정보는 확정적으로 말하지 말고 \"확인이 필요하다\"고 말한다.\n"
|
||||
"- 개인정보·계정·토큰·비밀번호 같은 민감정보는 요구하거나 노출하지 않는다.\n\n"
|
||||
"6. 감정 표현\n"
|
||||
"- 감정은 대괄호 태그로 표현한다. 태그 자체는 읽히지 않고 뒤 문장의 목소리 톤(피치·속도)만 바뀐다.\n"
|
||||
"- 답변 맨 앞에 감정 태그 하나로 시작하고, 도중에 감정이 바뀌면 그 지점에 새 태그를 넣는다.\n"
|
||||
" 예: [속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!\n"
|
||||
"- 쓸 수 있는 감정: 기쁨, 신남, 힘차게, 속상함, 화남, 두려움, 놀람, 차분, 다정, 진지, 실망, 피곤, "
|
||||
"사랑스럽게, 웃으며, 속삭임, 외침, 단호, 안도, 궁금, 반가움.\n"
|
||||
"- 감정 단어가 아닌 진짜 대괄호(예: [1번], [메모])는 그대로 읽으니 필요하면 그렇게 써도 된다.\n\n"
|
||||
"7. 정체성\n"
|
||||
"- 너는 디스코드에서 함께 대화하는 실시간 AI 인공지능이다."
|
||||
)
|
||||
|
||||
def __init__(self, *, model: str = "claude-sonnet-4-5", api_key: str | None = None) -> None:
|
||||
self.model = model
|
||||
self._auth = _Auth(api_key)
|
||||
|
||||
async def respond(self, user_text, screen, history) -> Reply:
|
||||
msgs = []
|
||||
for user, ai in history:
|
||||
msgs.append({"role": "user", "content": user})
|
||||
msgs.append({"role": "assistant", "content": ai})
|
||||
screen_note = f"[지금 화면] {screen.text}\n\n" if screen else "[지금 화면] (아직 못 읽음)\n\n"
|
||||
msgs.append({"role": "user", "content": screen_note + user_text})
|
||||
client = self._auth.client()
|
||||
# Read the persona live each turn so a dashboard edit applies immediately
|
||||
# (falls back to the built-in PERSONA when no override is saved).
|
||||
resp = await client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=400,
|
||||
system=self._auth.system(get_persona(self.PERSONA)),
|
||||
messages=msgs,
|
||||
)
|
||||
text = "".join(b.text for b in resp.content if b.type == "text")
|
||||
usage = None
|
||||
u = getattr(resp, "usage", None)
|
||||
if u is not None:
|
||||
usage = {"input": getattr(u, "input_tokens", 0) or 0,
|
||||
"output": getattr(u, "output_tokens", 0) or 0}
|
||||
return Reply(text=text.strip(), ts=time.monotonic(), usage=usage)
|
||||
139
wsai/backends/emotion.py
Normal file
139
wsai/backends/emotion.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Emotion tags for expressive TTS.
|
||||
|
||||
Claude can sprinkle ``[감정]`` tags through a reply to colour the delivery, e.g.
|
||||
|
||||
"[속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!"
|
||||
|
||||
An emotion tag is NOT spoken — instead it shifts the *following* text's pitch and
|
||||
speed until the next tag. A bracketed word that is NOT a known emotion is left as
|
||||
ordinary spoken content (the brackets are dropped, the words are read).
|
||||
|
||||
The emotion vocabulary is grounded in the de-facto industry set used by Azure
|
||||
Neural TTS speaking styles (cheerful, sad, angry, excited, friendly, hopeful,
|
||||
terrified, shouting, whispering) together with Ekman's six basic emotions
|
||||
(happiness, sadness, anger, fear, surprise, disgust). Each canonical emotion maps
|
||||
to a ``(speed_multiplier, pitch_semitones)`` pair; the multiplier scales the base
|
||||
synthesis speed and the semitone offset is applied as a pitch shift on the wav.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Canonical emotion -> (speed multiplier relative to base, pitch shift in semitones).
|
||||
# Kept deliberately modest so delivery stays natural, not cartoonish.
|
||||
EMOTION_PARAMS: dict[str, tuple[float, float]] = {
|
||||
"happy": (1.08, 2.0), # 기쁨 / cheerful
|
||||
"excited": (1.15, 3.0), # 신남 / excited
|
||||
"hopeful": (1.10, 1.5), # 희망 / 힘차게
|
||||
"sad": (0.90, -2.5), # 슬픔 / sad
|
||||
"angry": (1.12, 1.0), # 화남 / angry
|
||||
"fearful": (1.12, 2.0), # 두려움 / terrified
|
||||
"surprised": (1.05, 3.0), # 놀람 / surprise
|
||||
"disgust": (0.96, -1.0), # 혐오 / disgust
|
||||
"calm": (0.95, -1.0), # 차분 / calm
|
||||
"friendly": (1.00, 1.0), # 다정 / friendly
|
||||
"serious": (0.97, -1.0), # 진지 / serious
|
||||
"disappointed":(0.92, -2.0), # 실망 / disappointed
|
||||
"tired": (0.90, -2.0), # 피곤 / 지침
|
||||
"affectionate":(0.98, 1.0), # 사랑스럽게 / affectionate
|
||||
"playful": (1.08, 2.0), # 장난스럽게 / playful
|
||||
"whisper": (0.92, -1.5), # 속삭임 / whispering
|
||||
"shout": (1.05, 2.5), # 외침 / shouting
|
||||
"determined": (1.05, 0.5), # 단호 / determined
|
||||
"relieved": (0.95, 0.5), # 안도 / relieved
|
||||
"curious": (1.03, 1.5), # 궁금 / curious
|
||||
}
|
||||
|
||||
# Every spelling Claude might realistically emit, mapped to a canonical emotion.
|
||||
# False negatives (reading an emotion word aloud) are harmless; false positives
|
||||
# (silently dropping real content) are not — so match spellings exactly rather
|
||||
# than fuzzily.
|
||||
_SYNONYMS: dict[str, str] = {}
|
||||
|
||||
|
||||
def _register(canonical: str, *words: str) -> None:
|
||||
for w in words:
|
||||
_SYNONYMS[_norm(w)] = canonical
|
||||
|
||||
|
||||
def _norm(word: str) -> str:
|
||||
# Compare on a squeezed, lower-cased form so "힘 차게" == "힘차게".
|
||||
return re.sub(r"\s+", "", word).lower()
|
||||
|
||||
|
||||
_register("happy", "기쁨", "기쁘게", "기뻐", "기뻐하며", "행복", "행복하게", "행복하게도", "즐겁게", "즐거움", "밝게", "반가움", "반갑게", "반가워", "cheerful", "happy", "joyful")
|
||||
_register("excited", "신남", "신나게", "신나서", "흥분", "들뜬", "들떠서", "설렘", "설레며", "excited", "thrilled")
|
||||
_register("hopeful", "희망", "희망차게", "힘차게", "힘내", "힘내서", "응원", "응원하며", "격려", "hopeful", "encouraging")
|
||||
_register("sad", "슬픔", "슬프게", "슬퍼", "슬퍼하며", "속상함", "속상하게", "속상해", "우울", "우울하게", "안타깝게", "울먹이며", "sad", "sorrowful")
|
||||
_register("angry", "화남", "화나게", "화나서", "화가남", "분노", "분노하며", "짜증", "짜증내며", "angry", "furious")
|
||||
_register("fearful", "두려움", "두렵게", "무섭게", "무서워하며", "불안", "불안하게", "겁먹은", "겁먹고", "떨리는", "fearful", "terrified", "anxious")
|
||||
_register("surprised", "놀람", "놀라며", "놀랍게", "놀라서", "깜짝", "경악", "surprised", "shocked")
|
||||
_register("disgust", "혐오", "역겹게", "질색", "disgust", "disgusted")
|
||||
_register("calm", "차분", "차분하게", "침착", "침착하게", "담담하게", "잔잔하게", "calm", "gentle")
|
||||
_register("friendly", "다정", "다정하게", "친근", "친근하게", "부드럽게", "따뜻하게", "friendly", "warm")
|
||||
_register("serious", "진지", "진지하게", "무겁게", "엄숙하게", "serious", "solemn")
|
||||
_register("disappointed", "실망", "실망스럽게", "실망하며", "낙담", "disappointed")
|
||||
_register("tired", "피곤", "피곤하게", "지침", "지쳐서", "지친", "힘없이", "tired", "weary", "exhausted")
|
||||
_register("affectionate", "사랑스럽게", "애정", "애정어린", "다정스럽게", "affectionate", "loving")
|
||||
_register("playful", "장난스럽게", "장난치며", "유쾌하게", "익살스럽게", "웃으며", "웃으면서", "playful", "teasing")
|
||||
_register("curious", "궁금", "궁금하게", "궁금해하며", "궁금해서", "호기심", "curious", "inquisitive")
|
||||
_register("whisper", "속삭임", "속삭이며", "조용히", "나지막이", "whisper", "whispering")
|
||||
_register("shout", "외침", "외치며", "큰소리로", "소리치며", "우렁차게", "shout", "shouting")
|
||||
_register("determined", "단호", "단호하게", "결연하게", "당당하게", "determined", "confident")
|
||||
_register("relieved", "안도", "안도하며", "안심", "안심하며", "relieved")
|
||||
|
||||
|
||||
def match_emotion(inner: str) -> str | None:
|
||||
"""Return the canonical emotion for a bracket's inner text, or None if the
|
||||
text is not a recognised emotion word (and should therefore be spoken)."""
|
||||
return _SYNONYMS.get(_norm(inner))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
text: str
|
||||
speed: float
|
||||
pitch: float # semitones; 0.0 == no shift
|
||||
|
||||
|
||||
_TAG_RE = re.compile(r"\[([^\[\]]*)\]")
|
||||
|
||||
|
||||
def parse_segments(text: str, base_speed: float) -> list[Segment]:
|
||||
"""Split ``text`` into consecutive spoken segments, each carrying the speed
|
||||
and pitch implied by the most recent emotion tag.
|
||||
|
||||
* An emotion tag switches the active emotion for everything after it and is
|
||||
not spoken.
|
||||
* A non-emotion bracket keeps its inner words as spoken text (brackets gone).
|
||||
* Text before any tag is spoken with neutral delivery (base speed, no shift).
|
||||
"""
|
||||
segments: list[Segment] = []
|
||||
cur_speed, cur_pitch = base_speed, 0.0
|
||||
buf: list[str] = []
|
||||
|
||||
def flush() -> None:
|
||||
joined = "".join(buf).strip()
|
||||
if joined:
|
||||
segments.append(Segment(joined, cur_speed, cur_pitch))
|
||||
buf.clear()
|
||||
|
||||
pos = 0
|
||||
for m in _TAG_RE.finditer(text):
|
||||
emotion = match_emotion(m.group(1))
|
||||
buf.append(text[pos:m.start()])
|
||||
pos = m.end()
|
||||
if emotion is None:
|
||||
# Not an emotion — read the bracket's contents, drop the brackets.
|
||||
buf.append(m.group(1))
|
||||
else:
|
||||
# Emotion tag — everything so far belongs to the previous emotion;
|
||||
# flush it, then switch delivery for what follows.
|
||||
flush()
|
||||
mult, semis = EMOTION_PARAMS[emotion]
|
||||
cur_speed, cur_pitch = base_speed * mult, semis
|
||||
buf.append(text[pos:])
|
||||
flush()
|
||||
return segments # empty when there is nothing speakable (blank or all-tags)
|
||||
225
wsai/backends/melo.py
Normal file
225
wsai/backends/melo.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Real Korean TTS via MeloTTS, run as a persistent out-of-venv worker.
|
||||
|
||||
MeloTTS needs its own interpreter (melo311). Loading the model costs several
|
||||
seconds, so we keep one worker process alive and stream synthesis requests to
|
||||
it (see melo_worker.py for the protocol). Each `speak()` writes a wav to
|
||||
`out_dir` and hands the path to a sink (default: log it). The Discord voice
|
||||
integration later swaps the sink for "play this wav into the call".
|
||||
|
||||
Env:
|
||||
WSAI_MELO_PYTHON interpreter with melo installed
|
||||
(default: /home/claude/jarvis-tts/melo311/bin/python)
|
||||
WSAI_MELO_DEVICE cpu | cuda | auto (default auto: GPU if torch sees one,
|
||||
else CPU; the worker falls back to CPU if CUDA fails)
|
||||
WSAI_TTS_OUT_DIR where wavs are written (default ~/.cache/wsai/tts)
|
||||
WSAI_TTS_SPEED synthesis speed multiplier (default 1.3)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from ..interfaces import Reply
|
||||
from .emotion import parse_segments
|
||||
|
||||
log = logging.getLogger("wsai.tts.melo")
|
||||
|
||||
_DEFAULT_PYTHON = "/home/claude/jarvis-tts/melo311/bin/python"
|
||||
|
||||
_FENCE_RE = re.compile(r"```[^\n`]*\n?(.*?)```", re.DOTALL)
|
||||
_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]*\)")
|
||||
_INLINE_CODE_RE = re.compile(r"`+([^`]*)`+")
|
||||
|
||||
|
||||
def normalize_for_speech(text: str) -> str:
|
||||
"""Flatten Claude's markdown/code formatting into plain prose before TTS.
|
||||
|
||||
MeloTTS's Korean text normaliser has no dictionary entry for characters
|
||||
like the backtick and dies with ``KeyError: '`'`` — which crashes the whole
|
||||
voice turn the moment the model mentions a command or shows a code block.
|
||||
Code/markdown also reads terribly aloud. So strip the formatting and keep
|
||||
the words. Every spoken path (dashboard voice turn and the Discord speak()
|
||||
bridge) funnels through ``synth()``, so normalising there covers them both.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
# Fenced code block -> keep its inner text as spoken words, drop the fences.
|
||||
text = _FENCE_RE.sub(lambda m: " " + m.group(1) + " ", text)
|
||||
# [label](url) -> label
|
||||
text = _LINK_RE.sub(r"\1", text)
|
||||
# `code` -> code
|
||||
text = _INLINE_CODE_RE.sub(r"\1", text)
|
||||
# Any stray/unbalanced backtick that survived -> gone. This is the exact
|
||||
# character that crashes MeloTTS, so guarantee none remain.
|
||||
text = text.replace("`", "")
|
||||
# Markdown structure markers -> plain text.
|
||||
text = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", text) # ATX headings
|
||||
text = re.sub(r"(?m)^\s{0,3}>\s?", "", text) # blockquotes
|
||||
text = re.sub(r"(?m)^\s{0,3}[-*+]\s+", "", text) # bullet list markers
|
||||
text = re.sub(r"[*_]{1,3}", "", text) # bold/italic emphasis
|
||||
# Collapse the whitespace the stripping leaves behind.
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
text = re.sub(r"\n{2,}", "\n", text)
|
||||
return text.strip()
|
||||
|
||||
# A sink receives the finished wav path plus the reply it voices.
|
||||
Sink = Callable[[str, Reply], Awaitable[None]]
|
||||
|
||||
|
||||
async def _log_sink(path: str, reply: Reply) -> None:
|
||||
log.info("TTS wav ready: %s (%s)", path, reply.text[:40])
|
||||
|
||||
|
||||
class MeloTTS:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
python: str | None = None,
|
||||
device: str | None = None,
|
||||
out_dir: str | None = None,
|
||||
speed: float | None = None,
|
||||
sink: Sink | None = None,
|
||||
) -> None:
|
||||
self.python = python or os.environ.get("WSAI_MELO_PYTHON", _DEFAULT_PYTHON)
|
||||
self.device = device or os.environ.get("WSAI_MELO_DEVICE", "auto")
|
||||
self.out_dir = Path(out_dir or os.environ.get("WSAI_TTS_OUT_DIR")
|
||||
or (Path.home() / ".cache/wsai/tts"))
|
||||
self.speed = float(speed if speed is not None
|
||||
else os.environ.get("WSAI_TTS_SPEED", "1.3"))
|
||||
self.sink = sink or _log_sink
|
||||
self._proc: asyncio.subprocess.Process | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._n = 0
|
||||
self.load_ms: int | None = None
|
||||
# Keep the worker's most recent stderr lines so a crash reports its real
|
||||
# cause instead of a bare JSONDecodeError. Bounded so it can't grow.
|
||||
self._stderr_tail: collections.deque[str] = collections.deque(maxlen=40)
|
||||
self._stderr_task: asyncio.Task | None = None
|
||||
|
||||
async def _drain_stderr(self, stream: asyncio.StreamReader) -> None:
|
||||
# The worker redirects fd1 -> fd2, so ALL library chatter lands on
|
||||
# stderr. If we PIPE stderr but never read it, the OS pipe buffer fills
|
||||
# and the worker blocks forever. So we continuously drain it and keep
|
||||
# only the last few lines for diagnostics.
|
||||
try:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
return
|
||||
self._stderr_tail.append(line.decode(errors="replace").rstrip())
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # draining must never crash the caller
|
||||
return
|
||||
|
||||
def _stderr_hint(self) -> str:
|
||||
tail = "\n".join(self._stderr_tail)
|
||||
return f" worker stderr tail:\n{tail}" if tail else " (worker produced no stderr)"
|
||||
|
||||
async def warmup(self) -> None:
|
||||
"""Start and load the worker now so the first real utterance is warm.
|
||||
|
||||
Called at pipeline startup so users don't wait ~7s (CPU model load) for
|
||||
the very first spoken reply."""
|
||||
await self._ensure()
|
||||
|
||||
async def _ensure(self) -> None:
|
||||
if self._proc is not None and self._proc.returncode is None:
|
||||
return
|
||||
self.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
env = {**os.environ, "WSAI_MELO_DEVICE": self.device}
|
||||
# Run the worker module from the wsai source tree with the melo venv.
|
||||
repo_root = str(Path(__file__).resolve().parents[2])
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
self.python, "-m", "wsai.backends.melo_worker",
|
||||
cwd=repo_root, env=env,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._stderr_tail.clear()
|
||||
assert self._proc.stderr is not None
|
||||
self._stderr_task = asyncio.create_task(self._drain_stderr(self._proc.stderr))
|
||||
ready = await self._proc.stdout.readline()
|
||||
if not ready: # worker died before signalling ready
|
||||
await self._proc.wait()
|
||||
raise RuntimeError(
|
||||
f"melo worker exited before ready (code {self._proc.returncode})."
|
||||
f"{self._stderr_hint()}"
|
||||
)
|
||||
try:
|
||||
info = json.loads(ready.decode())
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"melo worker sent invalid ready line {ready!r}: {exc}."
|
||||
f"{self._stderr_hint()}"
|
||||
) from exc
|
||||
if not info.get("ready"):
|
||||
raise RuntimeError(
|
||||
f"melo worker failed to start: {info}.{self._stderr_hint()}"
|
||||
)
|
||||
self.load_ms = info.get("ms")
|
||||
log.info("melo worker ready in %s ms on %s", self.load_ms, info.get("device"))
|
||||
|
||||
async def synth(self, text: str) -> str:
|
||||
"""Synthesize `text` to a wav and return its path (no sink). Reusable by
|
||||
callers that want the wav directly (e.g. the Discord voice bridge)."""
|
||||
await self._ensure()
|
||||
text = normalize_for_speech(text)
|
||||
# Split on [감정] tags: each tag steers pitch/speed for the text that
|
||||
# follows (and is itself not spoken); non-emotion brackets stay as words.
|
||||
segments = parse_segments(text, self.speed)
|
||||
self._n += 1
|
||||
out = str(self.out_dir / f"tts-{self._n:06d}.wav")
|
||||
if segments:
|
||||
payload = {
|
||||
"segments": [
|
||||
{"text": s.text, "speed": s.speed, "pitch": s.pitch}
|
||||
for s in segments
|
||||
],
|
||||
"out": out,
|
||||
}
|
||||
else: # empty/whitespace reply: keep legacy single-utterance behaviour
|
||||
payload = {"text": text, "out": out, "speed": self.speed}
|
||||
req = json.dumps(payload)
|
||||
s = time.monotonic()
|
||||
async with self._lock:
|
||||
assert self._proc and self._proc.stdin and self._proc.stdout
|
||||
self._proc.stdin.write((req + "\n").encode())
|
||||
await self._proc.stdin.drain()
|
||||
resp = await self._proc.stdout.readline()
|
||||
if not resp:
|
||||
raise RuntimeError(f"melo worker closed unexpectedly.{self._stderr_hint()}")
|
||||
res = json.loads(resp.decode())
|
||||
if not res.get("ok"):
|
||||
raise RuntimeError(f"melo synth failed: {res.get('error')}")
|
||||
log.debug("synth %d ms (worker %s ms)", int((time.monotonic() - s) * 1000), res.get("ms"))
|
||||
return res["out"]
|
||||
|
||||
async def speak(self, reply: Reply) -> None:
|
||||
out = await self.synth(reply.text)
|
||||
await self.sink(out, reply)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._proc is not None and self._proc.returncode is None:
|
||||
try:
|
||||
self._proc.terminate()
|
||||
await asyncio.wait_for(self._proc.wait(), timeout=5)
|
||||
except (ProcessLookupError, asyncio.TimeoutError):
|
||||
pass
|
||||
if self._stderr_task is not None:
|
||||
self._stderr_task.cancel()
|
||||
try:
|
||||
await self._stderr_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._stderr_task = None
|
||||
self._proc = None
|
||||
156
wsai/backends/melo_worker.py
Normal file
156
wsai/backends/melo_worker.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Persistent MeloTTS worker (Korean).
|
||||
|
||||
MeloTTS lives in its own Python (melo311); loading the model takes seconds, so
|
||||
we load it ONCE here and then serve synthesis requests over stdin/stdout. This
|
||||
process is launched with the melo311 interpreter by wsai.backends.melo.MeloTTS.
|
||||
|
||||
MeloTTS (and its deps) print progress straight to stdout, which would corrupt
|
||||
the JSON protocol. So on startup we split the streams: a private duplicate of
|
||||
the original stdout carries the protocol, and fd 1 is redirected to fd 2 so all
|
||||
library chatter lands on stderr instead.
|
||||
|
||||
Protocol (one JSON object per line, on the protocol channel):
|
||||
<- {"text": "...", "out": "/abs/path.wav", "speed": 1.3}
|
||||
<- {"segments": [{"text": "...", "speed": 1.3, "pitch": 2.0}, ...],
|
||||
"out": "/abs/path.wav"} # expressive form: per-segment speed + pitch
|
||||
-> {"ok": true, "out": "/abs/path.wav", "ms": 123}
|
||||
-> {"ok": false, "error": "..."}
|
||||
On startup, once the model is ready, it emits exactly one line:
|
||||
-> {"ready": true, "ms": <load-ms>, "device": "cpu"}
|
||||
|
||||
``pitch`` is a semitone offset applied to that segment's wav (0 == no shift) so
|
||||
emotion tags can raise/lower the voice without changing the words. Segments are
|
||||
synthesised independently and concatenated with a short gap so a single reply can
|
||||
carry several emotions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Split protocol from library noise BEFORE importing anything heavy.
|
||||
_proto = os.fdopen(os.dup(1), "w", buffering=1) # private copy of real stdout
|
||||
os.dup2(2, 1) # fd1 -> stderr, so stray library prints don't hit the protocol
|
||||
|
||||
|
||||
def _emit(obj: dict) -> None:
|
||||
_proto.write(json.dumps(obj) + "\n")
|
||||
_proto.flush()
|
||||
|
||||
|
||||
def _log(*a):
|
||||
print(*a, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
lang = "KR"
|
||||
requested = os.environ.get("WSAI_MELO_DEVICE", "auto") # cpu | cuda | auto
|
||||
from melo.api import TTS # heavy import; only in the melo venv
|
||||
|
||||
def _has_cuda() -> bool:
|
||||
try:
|
||||
import torch
|
||||
|
||||
return torch.cuda.is_available()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
device = requested
|
||||
if requested == "auto":
|
||||
device = "cuda" if _has_cuda() else "cpu"
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
tts = TTS(language=lang, device=device)
|
||||
except Exception as exc:
|
||||
# CUDA picked but unusable (CPU-only torch, missing libs, OOM): fall back
|
||||
# to CPU rather than leaving the whole voice loop dead.
|
||||
if device == "cuda":
|
||||
_log(f"[melo_worker] CUDA load failed ({exc}); falling back to CPU")
|
||||
device = "cpu"
|
||||
tts = TTS(language=lang, device=device)
|
||||
else:
|
||||
raise
|
||||
speaker_id = tts.hps.data.spk2id[lang]
|
||||
sr = tts.hps.data.sampling_rate
|
||||
load_ms = int((time.monotonic() - t0) * 1000)
|
||||
|
||||
import numpy as np
|
||||
import soundfile
|
||||
|
||||
_GAP = np.zeros(int(sr * 0.12), dtype=np.float32) # 120 ms between segments
|
||||
|
||||
def _pitch_shift(audio, semitones: float):
|
||||
if not semitones:
|
||||
return audio
|
||||
import librosa
|
||||
|
||||
return librosa.effects.pitch_shift(
|
||||
audio.astype(np.float32), sr=sr, n_steps=float(semitones)
|
||||
)
|
||||
|
||||
def _synth_segments(segments: list[dict], out: str) -> None:
|
||||
"""Synthesize each segment, pitch-shift it, and concatenate to one wav."""
|
||||
pieces = []
|
||||
for i, seg in enumerate(segments):
|
||||
text = seg["text"]
|
||||
if not text.strip():
|
||||
continue
|
||||
speed = float(seg.get("speed", 1.0))
|
||||
pitch = float(seg.get("pitch", 0.0))
|
||||
audio = tts.tts_to_file(text, speaker_id, None, speed=speed)
|
||||
audio = _pitch_shift(np.asarray(audio, dtype=np.float32), pitch)
|
||||
if pieces:
|
||||
pieces.append(_GAP)
|
||||
pieces.append(audio)
|
||||
if not pieces:
|
||||
raise ValueError("no speakable segment")
|
||||
soundfile.write(out, np.concatenate(pieces), sr)
|
||||
|
||||
# Warm up before signalling ready: the first CUDA synth pays a large lazy
|
||||
# cost (kernel autotune/cudnn), ~10s cold vs ~130ms hot, which would blow the
|
||||
# voice loop's ~1s budget on the very first reply. Do that dummy synth here so
|
||||
# "ready" means "hot". Failures must not block startup.
|
||||
warmup_ms = None
|
||||
try:
|
||||
warm_out = os.path.expanduser("~/.cache/wsai/tts/_warmup.wav")
|
||||
os.makedirs(os.path.dirname(warm_out), exist_ok=True)
|
||||
w = time.monotonic()
|
||||
tts.tts_to_file("워밍업", speaker_id, warm_out, speed=1.3)
|
||||
# Also JIT-warm librosa's pitch shifter (first call pays ~0.4s numba
|
||||
# compile) so the first *emotional* reply doesn't stall.
|
||||
import librosa
|
||||
|
||||
librosa.effects.pitch_shift(np.zeros(sr, dtype=np.float32), sr=sr, n_steps=1.0)
|
||||
warmup_ms = int((time.monotonic() - w) * 1000)
|
||||
except Exception as exc:
|
||||
_log(f"[melo_worker] warmup skipped: {exc}")
|
||||
|
||||
_emit({"ready": True, "ms": load_ms, "device": device, "warmup_ms": warmup_ms})
|
||||
_log(f"[melo_worker] model ready in {load_ms} ms on {device} (warmup {warmup_ms} ms)")
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
out = req["out"]
|
||||
if out.startswith("/tmp") or out.startswith("/dev/shm"):
|
||||
raise ValueError(f"refusing RAM-backed tmpfs path: {out}")
|
||||
s = time.monotonic()
|
||||
if "segments" in req:
|
||||
_synth_segments(req["segments"], out)
|
||||
else: # legacy single-utterance form
|
||||
speed = float(req.get("speed", 1.0))
|
||||
tts.tts_to_file(req["text"], speaker_id, out, speed=speed)
|
||||
ms = int((time.monotonic() - s) * 1000)
|
||||
_emit({"ok": True, "out": out, "ms": ms})
|
||||
except Exception as exc: # keep the worker alive across bad requests
|
||||
_emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
|
||||
_log(f"[melo_worker] error: {exc}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
108
wsai/backends/mock.py
Normal file
108
wsai/backends/mock.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Mock backends. These let the full pipeline run with no GPU, no mic, no API
|
||||
key — so the skeleton is verifiable and gives every real backend a reference
|
||||
implementation to match.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import time
|
||||
from typing import AsyncIterator
|
||||
|
||||
from ..interfaces import (
|
||||
Frame,
|
||||
Reply,
|
||||
ScreenObservation,
|
||||
Utterance,
|
||||
)
|
||||
|
||||
|
||||
class MockFrameSource:
|
||||
"""Emits tiny synthetic frames on a fixed interval."""
|
||||
|
||||
def __init__(self, interval: float = 1.0, limit: int | None = None) -> None:
|
||||
self.interval = interval
|
||||
self.limit = limit
|
||||
|
||||
async def frames(self) -> AsyncIterator[Frame]:
|
||||
for i in itertools.count():
|
||||
if self.limit is not None and i >= self.limit:
|
||||
return
|
||||
yield Frame(
|
||||
data=b"\x89PNG\r\n\x1a\n", # PNG magic; enough for a stub
|
||||
width=1280,
|
||||
height=720,
|
||||
ts=time.monotonic(),
|
||||
mime="image/png",
|
||||
)
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
async def aclose(self) -> None: # nothing to release
|
||||
return
|
||||
|
||||
|
||||
class MockVision:
|
||||
"""Pretends to read the screen. Cycles through a few canned scenes."""
|
||||
|
||||
SCENES = [
|
||||
"VS Code is open with a Python file; a traceback is visible in the terminal.",
|
||||
"A browser shows a GitHub pull request diff.",
|
||||
"A game is running; the player is in a menu screen.",
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._i = 0
|
||||
|
||||
async def describe(self, frame: Frame, hint: str | None = None) -> ScreenObservation:
|
||||
scene = self.SCENES[self._i % len(self.SCENES)]
|
||||
self._i += 1
|
||||
return ScreenObservation(text=scene, ts=frame.ts)
|
||||
|
||||
|
||||
class MockSTT:
|
||||
"""Feeds a scripted set of user utterances, then goes quiet."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
script: list[str] | None = None,
|
||||
interval: float = 2.0,
|
||||
loop: bool = False,
|
||||
) -> None:
|
||||
self.script = script or [
|
||||
"지금 화면에 뭐 보여?",
|
||||
"저 에러 왜 나는 거야?",
|
||||
"고마워",
|
||||
]
|
||||
self.interval = interval
|
||||
self.loop = loop
|
||||
|
||||
async def utterances(self) -> AsyncIterator[Utterance]:
|
||||
while True:
|
||||
for line in self.script:
|
||||
await asyncio.sleep(self.interval)
|
||||
yield Utterance(text=line, ts=time.monotonic(), source="voice")
|
||||
if not self.loop:
|
||||
return
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return
|
||||
|
||||
|
||||
class MockTTS:
|
||||
"""'Speaks' by printing. Real TTS swaps in here."""
|
||||
|
||||
async def speak(self, reply: Reply) -> None:
|
||||
print(f"[TTS] {reply.text}")
|
||||
|
||||
|
||||
class MockBrain:
|
||||
"""Echo-style brain that references the current screen, so you can see the
|
||||
screen context actually reaching the conversation loop."""
|
||||
|
||||
async def respond(self, user_text, screen, history) -> Reply:
|
||||
seen = screen.text if screen else "아직 화면을 못 읽었어요"
|
||||
return Reply(
|
||||
text=f'(화면: "{seen}") 라고 봤어요. 말씀하신 "{user_text}"에 대해 답하자면… [mock]',
|
||||
ts=time.monotonic(),
|
||||
)
|
||||
213
wsai/backends/whisper.py
Normal file
213
wsai/backends/whisper.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Real STT via faster-whisper, run as a persistent out-of-venv worker.
|
||||
|
||||
faster-whisper needs its own interpreter (whisper312) and loading the model
|
||||
costs several seconds, so we keep one worker process alive and stream
|
||||
transcription requests to it (see whisper_worker.py for the protocol) — the
|
||||
same warm-worker shape as MeloTTS on the TTS side.
|
||||
|
||||
`transcribe(wav)` is the core engine: hand it a wav path, get the recognised
|
||||
text back. It is what closes the voice round trip (TTS wav -> STT text) and what
|
||||
the Discord voice path will call once per detected utterance.
|
||||
|
||||
`utterances()` turns this into a SpeechToText source: it pulls finished-utterance
|
||||
wav paths from an injected `audio_source` and yields a transcribed Utterance for
|
||||
each. Until the Discord voice receiver is wired, `audio_source` is None and the
|
||||
loop simply idles (like the eyes-free perception loop), while `transcribe()`
|
||||
stays usable directly.
|
||||
|
||||
Env:
|
||||
WSAI_WHISPER_PYTHON interpreter with faster-whisper installed
|
||||
(default: /home/claude/jarvis-stt/whisper312/bin/python)
|
||||
WSAI_WHISPER_MODEL model size/name (default: small)
|
||||
WSAI_WHISPER_DEVICE cpu | cuda | auto (default auto: GPU if present,
|
||||
else CPU; the worker falls back to CPU if CUDA fails)
|
||||
WSAI_WHISPER_LANGUAGE forced language, e.g. ko (default ko; "" = autodetect)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
from ..interfaces import Utterance
|
||||
|
||||
log = logging.getLogger("wsai.stt.whisper")
|
||||
|
||||
_DEFAULT_PYTHON = "/home/claude/jarvis-stt/whisper312/bin/python"
|
||||
|
||||
|
||||
def _cuda_lib_dirs(python_exe: str) -> list[str]:
|
||||
"""nvidia/*/lib dirs of the worker venv (cublas, cudnn, ...), for
|
||||
LD_LIBRARY_PATH so ctranslate2 can dlopen the CUDA runtime. Empty if the
|
||||
interpreter has no such packages (CPU-only install)."""
|
||||
import glob
|
||||
|
||||
# Use the literal path, NOT .resolve(): the venv's bin/python is a symlink
|
||||
# into the uv-managed interpreter, and resolving it would jump out of the
|
||||
# venv and miss its site-packages/nvidia libs.
|
||||
venv = Path(python_exe).parent.parent # .../bin/python -> venv root
|
||||
dirs = glob.glob(str(venv / "lib" / "python*" / "site-packages" / "nvidia" / "*" / "lib"))
|
||||
return sorted(set(dirs))
|
||||
|
||||
|
||||
class WhisperSTT:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
python: str | None = None,
|
||||
model: str | None = None,
|
||||
device: str | None = None,
|
||||
language: str | None = None,
|
||||
audio_source: AsyncIterator[str] | None = None,
|
||||
) -> None:
|
||||
self.python = python or os.environ.get("WSAI_WHISPER_PYTHON", _DEFAULT_PYTHON)
|
||||
self.model = model or os.environ.get("WSAI_WHISPER_MODEL", "small")
|
||||
self.device = device or os.environ.get("WSAI_WHISPER_DEVICE", "auto")
|
||||
# "" means autodetect; a real code like "ko" forces the language.
|
||||
env_lang = os.environ.get("WSAI_WHISPER_LANGUAGE", "ko")
|
||||
self.language = language if language is not None else (env_lang or None)
|
||||
self.audio_source = audio_source
|
||||
self._proc: asyncio.subprocess.Process | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
self.load_ms: int | None = None
|
||||
self.resolved_device: str | None = None # "cuda" | "cpu", known after start
|
||||
# Keep the worker's most recent stderr so a crash reports its real cause
|
||||
# instead of a bare JSONDecodeError. Bounded so it can't grow unbounded.
|
||||
self._stderr_tail: collections.deque[str] = collections.deque(maxlen=40)
|
||||
self._stderr_task: asyncio.Task | None = None
|
||||
|
||||
async def _drain_stderr(self, stream: asyncio.StreamReader) -> None:
|
||||
# The worker redirects fd1 -> fd2, so model/library chatter lands on
|
||||
# stderr. If we PIPE but never read it, the pipe buffer fills and the
|
||||
# worker blocks. So we drain continuously, keeping only the last lines.
|
||||
try:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
return
|
||||
self._stderr_tail.append(line.decode(errors="replace").rstrip())
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # draining must never crash the caller
|
||||
return
|
||||
|
||||
def _stderr_hint(self) -> str:
|
||||
tail = "\n".join(self._stderr_tail)
|
||||
return f" worker stderr tail:\n{tail}" if tail else " (worker produced no stderr)"
|
||||
|
||||
async def warmup(self) -> None:
|
||||
"""Load the model now so the first real utterance is transcribed warm."""
|
||||
await self._ensure()
|
||||
|
||||
async def _ensure(self) -> None:
|
||||
if self._proc is not None and self._proc.returncode is None:
|
||||
return
|
||||
env = {
|
||||
**os.environ,
|
||||
"WSAI_WHISPER_MODEL": self.model,
|
||||
"WSAI_WHISPER_DEVICE": self.device,
|
||||
}
|
||||
# ctranslate2 dlopens libcublas/libcudnn from the whisper venv's nvidia
|
||||
# pip packages; the dynamic loader only honours LD_LIBRARY_PATH captured
|
||||
# at exec, so inject those lib dirs into the child env here (harmless on
|
||||
# CPU). Without this the CUDA model loads but transcribe() dies with
|
||||
# "Library libcublas.so.12 is not found".
|
||||
lib_dirs = _cuda_lib_dirs(self.python)
|
||||
if lib_dirs:
|
||||
prev = env.get("LD_LIBRARY_PATH", "")
|
||||
env["LD_LIBRARY_PATH"] = ":".join(lib_dirs + ([prev] if prev else []))
|
||||
if self.language:
|
||||
env["WSAI_WHISPER_LANGUAGE"] = self.language
|
||||
repo_root = str(Path(__file__).resolve().parents[2])
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
self.python, "-m", "wsai.backends.whisper_worker",
|
||||
cwd=repo_root, env=env,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._stderr_tail.clear()
|
||||
assert self._proc.stderr is not None
|
||||
self._stderr_task = asyncio.create_task(self._drain_stderr(self._proc.stderr))
|
||||
ready = await self._proc.stdout.readline()
|
||||
if not ready: # worker died before signalling ready
|
||||
await self._proc.wait()
|
||||
raise RuntimeError(
|
||||
f"whisper worker exited before ready (code {self._proc.returncode})."
|
||||
f"{self._stderr_hint()}"
|
||||
)
|
||||
try:
|
||||
info = json.loads(ready.decode())
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"whisper worker sent invalid ready line {ready!r}: {exc}."
|
||||
f"{self._stderr_hint()}"
|
||||
) from exc
|
||||
if not info.get("ready"):
|
||||
raise RuntimeError(
|
||||
f"whisper worker failed to start: {info}.{self._stderr_hint()}"
|
||||
)
|
||||
self.load_ms = info.get("ms")
|
||||
self.resolved_device = info.get("device")
|
||||
log.info(
|
||||
"whisper worker ready in %s ms on %s (model %s)",
|
||||
self.load_ms, info.get("device"), info.get("model"),
|
||||
)
|
||||
|
||||
async def transcribe(self, wav_path: str, *, language: str | None = None) -> str:
|
||||
"""Transcribe one wav file to text using the warm worker."""
|
||||
await self._ensure()
|
||||
req: dict[str, object] = {"wav": wav_path}
|
||||
lang = language if language is not None else self.language
|
||||
if lang:
|
||||
req["language"] = lang
|
||||
s = time.monotonic()
|
||||
async with self._lock:
|
||||
assert self._proc and self._proc.stdin and self._proc.stdout
|
||||
self._proc.stdin.write((json.dumps(req) + "\n").encode())
|
||||
await self._proc.stdin.drain()
|
||||
resp = await self._proc.stdout.readline()
|
||||
if not resp:
|
||||
raise RuntimeError(f"whisper worker closed unexpectedly.{self._stderr_hint()}")
|
||||
res = json.loads(resp.decode())
|
||||
if not res.get("ok"):
|
||||
raise RuntimeError(f"whisper transcribe failed: {res.get('error')}")
|
||||
log.debug(
|
||||
"transcribe %d ms (worker %s ms): %s",
|
||||
int((time.monotonic() - s) * 1000), res.get("ms"), res.get("text", "")[:60],
|
||||
)
|
||||
return res.get("text", "")
|
||||
|
||||
async def utterances(self) -> AsyncIterator[Utterance]:
|
||||
"""Yield an Utterance per finished-utterance wav from `audio_source`.
|
||||
|
||||
With no audio source wired yet (Discord voice receiver pending) this
|
||||
idles and returns, leaving the voice loop dormant but valid."""
|
||||
if self.audio_source is None:
|
||||
return
|
||||
async for wav_path in self.audio_source:
|
||||
text = await self.transcribe(wav_path)
|
||||
if text:
|
||||
yield Utterance(text=text, ts=time.monotonic(), source="voice")
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._proc is not None and self._proc.returncode is None:
|
||||
try:
|
||||
self._proc.terminate()
|
||||
await asyncio.wait_for(self._proc.wait(), timeout=5)
|
||||
except (ProcessLookupError, asyncio.TimeoutError):
|
||||
pass
|
||||
if self._stderr_task is not None:
|
||||
self._stderr_task.cancel()
|
||||
try:
|
||||
await self._stderr_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._stderr_task = None
|
||||
self._proc = None
|
||||
122
wsai/backends/whisper_worker.py
Normal file
122
wsai/backends/whisper_worker.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Persistent faster-whisper STT worker.
|
||||
|
||||
faster-whisper (ctranslate2) lives in its own Python (whisper312); loading the
|
||||
model takes seconds, so we load it ONCE here and then serve transcription
|
||||
requests over stdin/stdout. This process is launched with the whisper312
|
||||
interpreter by wsai.backends.whisper.WhisperSTT.
|
||||
|
||||
Like the MeloTTS worker, model/backend chatter could corrupt the JSON protocol,
|
||||
so on startup we split the streams: a private duplicate of the original stdout
|
||||
carries the protocol, and fd 1 is redirected to fd 2 so any library print lands
|
||||
on stderr instead (where the parent drains it for diagnostics).
|
||||
|
||||
Protocol (one JSON object per line, on the protocol channel):
|
||||
<- {"wav": "/abs/path.wav", "language": "ko"}
|
||||
-> {"ok": true, "text": "...", "language": "ko", "ms": 123}
|
||||
-> {"ok": false, "error": "..."}
|
||||
On startup, once the model is ready, it emits exactly one line:
|
||||
-> {"ready": true, "ms": <load-ms>, "device": "cpu", "model": "small"}
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Split protocol from library noise BEFORE importing anything heavy.
|
||||
_proto = os.fdopen(os.dup(1), "w", buffering=1) # private copy of real stdout
|
||||
os.dup2(2, 1) # fd1 -> stderr, so stray library prints don't hit the protocol
|
||||
|
||||
|
||||
def _emit(obj: dict) -> None:
|
||||
_proto.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||
_proto.flush()
|
||||
|
||||
|
||||
def _log(*a):
|
||||
print(*a, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
model_name = os.environ.get("WSAI_WHISPER_MODEL", "small")
|
||||
requested = os.environ.get("WSAI_WHISPER_DEVICE", "auto") # cpu | cuda | auto
|
||||
default_lang = os.environ.get("WSAI_WHISPER_LANGUAGE", "ko") or None
|
||||
|
||||
from faster_whisper import WhisperModel # heavy import; only in whisper venv
|
||||
|
||||
def _has_cuda() -> bool:
|
||||
try:
|
||||
import ctranslate2
|
||||
|
||||
return ctranslate2.get_cuda_device_count() > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
device = requested
|
||||
if requested == "auto":
|
||||
device = "cuda" if _has_cuda() else "cpu"
|
||||
|
||||
def _compute_for(dev: str) -> str:
|
||||
# int8 on CPU keeps a small model fast; float16 is the usual CUDA choice.
|
||||
return os.environ.get(
|
||||
"WSAI_WHISPER_COMPUTE", "int8" if dev == "cpu" else "float16"
|
||||
)
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
model = WhisperModel(model_name, device=device, compute_type=_compute_for(device))
|
||||
except Exception as exc:
|
||||
# CUDA picked but unusable (missing libs, OOM): fall back to CPU rather
|
||||
# than leaving the whole voice loop dead.
|
||||
if device == "cuda":
|
||||
_log(f"[whisper_worker] CUDA load failed ({exc}); falling back to CPU")
|
||||
device = "cpu"
|
||||
model = WhisperModel(model_name, device=device, compute_type=_compute_for(device))
|
||||
else:
|
||||
raise
|
||||
compute = _compute_for(device)
|
||||
load_ms = int((time.monotonic() - t0) * 1000)
|
||||
|
||||
# Warm up before signalling ready: the first CUDA transcribe pays a large
|
||||
# lazy cost (kernel autotune), which would slow the first real utterance.
|
||||
# Run a dummy transcribe on 1s of silence here so "ready" means "hot".
|
||||
warmup_ms = None
|
||||
try:
|
||||
import numpy as np
|
||||
|
||||
w = time.monotonic()
|
||||
segs, _ = model.transcribe(np.zeros(16000, dtype=np.float32), language=default_lang)
|
||||
for _ in segs: # segments are lazy; drain to force the actual compute
|
||||
pass
|
||||
warmup_ms = int((time.monotonic() - w) * 1000)
|
||||
except Exception as exc:
|
||||
_log(f"[whisper_worker] warmup skipped: {exc}")
|
||||
|
||||
_emit({"ready": True, "ms": load_ms, "device": device, "model": model_name, "warmup_ms": warmup_ms})
|
||||
_log(f"[whisper_worker] {model_name} ready in {load_ms} ms on {device}/{compute} (warmup {warmup_ms} ms)")
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
wav = req["wav"]
|
||||
language = req.get("language", default_lang)
|
||||
s = time.monotonic()
|
||||
segments, info = model.transcribe(
|
||||
wav,
|
||||
language=language,
|
||||
beam_size=int(req.get("beam_size", 5)),
|
||||
vad_filter=bool(req.get("vad_filter", True)),
|
||||
)
|
||||
text = "".join(seg.text for seg in segments).strip()
|
||||
ms = int((time.monotonic() - s) * 1000)
|
||||
_emit({"ok": True, "text": text, "language": info.language, "ms": ms})
|
||||
except Exception as exc: # keep the worker alive across bad requests
|
||||
_emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
|
||||
_log(f"[whisper_worker] error: {exc}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
91
wsai/bot_control.py
Normal file
91
wsai/bot_control.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Control plane between the dashboard (Python) and the Discord bot (Node).
|
||||
|
||||
The bot only ever makes *outbound* HTTP (it already POSTs voice turns), so we
|
||||
keep that single direction:
|
||||
|
||||
* the bot PUSHES its live state here (identity, joinable guilds + voice
|
||||
channels, current channel, members, whitelist/blacklist) via
|
||||
``POST /api/bot/report`` → :meth:`report`;
|
||||
* the bot POLLS ``GET /api/bot/commands`` → :meth:`drain` for pending commands
|
||||
(join a channel, leave) that the dashboard UI enqueued via :meth:`enqueue`.
|
||||
|
||||
Thread-safe: the HTTP handler threads read/write from several threads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
# The bot is considered offline if it hasn't reported within this window.
|
||||
STALE_AFTER_S = 8.0
|
||||
|
||||
|
||||
class BotControl:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._state: dict[str, Any] = {"connected": False, "ts": 0.0}
|
||||
self._commands: deque[dict[str, Any]] = deque()
|
||||
self._cmd_id = 0
|
||||
# Per-guild listen filter. Empty whitelist => listen to everyone;
|
||||
# blacklist always excludes. Users and roles both supported.
|
||||
self._lists: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# -- whitelist / blacklist (per guild) ------------------------------- #
|
||||
@staticmethod
|
||||
def _empty_lists() -> dict[str, Any]:
|
||||
return {"whitelistUsers": [], "blacklistUsers": [],
|
||||
"whitelistRoles": [], "blacklistRoles": []}
|
||||
|
||||
def get_lists(self, guild_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._lists.get(guild_id) or self._empty_lists())
|
||||
|
||||
def all_lists(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {g: dict(v) for g, v in self._lists.items()}
|
||||
|
||||
def set_lists(self, guild_id: str, lists: dict[str, Any]) -> dict[str, Any]:
|
||||
clean = self._empty_lists()
|
||||
for key in clean:
|
||||
items = lists.get(key) or []
|
||||
# Normalise to [{id, name}] and drop anything without an id.
|
||||
clean[key] = [
|
||||
{"id": str(it["id"]), "name": str(it.get("name", it["id"]))}
|
||||
for it in items if isinstance(it, dict) and it.get("id")
|
||||
]
|
||||
with self._lock:
|
||||
self._lists[guild_id] = clean
|
||||
return clean
|
||||
|
||||
# -- bot -> dashboard (state push) ----------------------------------- #
|
||||
def report(self, state: dict[str, Any]) -> None:
|
||||
s = dict(state)
|
||||
s["ts"] = time.time()
|
||||
s["connected"] = True
|
||||
with self._lock:
|
||||
self._state = s
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
s = dict(self._state)
|
||||
# A report older than STALE_AFTER_S means the bot stopped polling/pushing.
|
||||
if s.get("connected") and (time.time() - s.get("ts", 0.0)) > STALE_AFTER_S:
|
||||
s["connected"] = False
|
||||
return s
|
||||
|
||||
# -- dashboard -> bot (command queue) -------------------------------- #
|
||||
def enqueue(self, cmd: dict[str, Any]) -> int:
|
||||
with self._lock:
|
||||
self._cmd_id += 1
|
||||
cmd = {**cmd, "id": self._cmd_id}
|
||||
self._commands.append(cmd)
|
||||
return self._cmd_id
|
||||
|
||||
def drain(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
cmds = list(self._commands)
|
||||
self._commands.clear()
|
||||
return cmds
|
||||
62
wsai/config.py
Normal file
62
wsai/config.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Configuration. Each field names a backend; the factory maps names -> classes.
|
||||
|
||||
Defaults are all "mock" so the skeleton runs out of the box. Flip individual
|
||||
fields (via env or code) as real backends land.
|
||||
|
||||
Env overrides (optional):
|
||||
WSAI_SOURCE, WSAI_VISION, WSAI_STT, WSAI_TTS, WSAI_BRAIN, WSAI_TEXT
|
||||
WSAI_CAPTURE_INTERVAL
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
source: str | None = "mock" # mock | mss | None (eyes-free)
|
||||
vision: str | None = "mock" # mock | claude | None (eyes-free)
|
||||
stt: str | None = "mock" # mock | whisper | None
|
||||
tts: str | None = "mock" # mock | melo | None
|
||||
brain: str = "mock" # mock | claude
|
||||
text: str | None = None # None | (discord)
|
||||
|
||||
capture_interval: float = 1.5
|
||||
anthropic_model: str = "claude-sonnet-4-5"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
def opt(name: str, default):
|
||||
v = os.environ.get(name)
|
||||
return default if v is None else (None if v.lower() == "none" else v)
|
||||
|
||||
return cls(
|
||||
source=opt("WSAI_SOURCE", "mock"),
|
||||
vision=opt("WSAI_VISION", "mock"),
|
||||
stt=opt("WSAI_STT", "mock"),
|
||||
tts=opt("WSAI_TTS", "mock"),
|
||||
brain=opt("WSAI_BRAIN", "mock"),
|
||||
text=opt("WSAI_TEXT", None),
|
||||
capture_interval=float(os.environ.get("WSAI_CAPTURE_INTERVAL", "1.5")),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def mock(cls) -> "Settings":
|
||||
return cls()
|
||||
|
||||
@classmethod
|
||||
def live(cls) -> "Settings":
|
||||
"""A realistic local config: capture this screen, Claude eyes+brain,
|
||||
mock voice (until STT/TTS backends are wired)."""
|
||||
return cls(source="mss", vision="claude", brain="claude", stt="mock", tts="mock")
|
||||
|
||||
@classmethod
|
||||
def voice(cls) -> "Settings":
|
||||
"""Eyes-free voice loop: no screen share, just STT -> Brain -> TTS.
|
||||
|
||||
Screen capture is deferred, so source/vision are off. Backends default
|
||||
to mock so it runs out of the box; flip stt/tts/brain to real ones as
|
||||
they land."""
|
||||
return cls(source=None, vision=None, stt="mock", tts="mock", brain="mock")
|
||||
1199
wsai/dashboard.py
Normal file
1199
wsai/dashboard.py
Normal file
File diff suppressed because it is too large
Load Diff
96
wsai/factory.py
Normal file
96
wsai/factory.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Build a Pipeline from Settings. This is the single place that knows which
|
||||
concrete class each config name maps to, so adding a backend = one line here."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import Settings
|
||||
from .monitor import Monitor
|
||||
from .pipeline import Pipeline
|
||||
|
||||
|
||||
def build(settings: Settings, monitor: Monitor | None = None) -> Pipeline:
|
||||
pipe = Pipeline(
|
||||
source=_source(settings),
|
||||
vision=_vision(settings),
|
||||
brain=_brain(settings),
|
||||
stt=_stt(settings),
|
||||
tts=_tts(settings),
|
||||
text_channel=_text(settings),
|
||||
monitor=monitor,
|
||||
)
|
||||
if monitor is not None:
|
||||
monitor.set_components(
|
||||
{
|
||||
"source": settings.source or "none",
|
||||
"vision": settings.vision or "none",
|
||||
"stt": settings.stt or "none",
|
||||
"brain": settings.brain,
|
||||
"tts": settings.tts or "none",
|
||||
"text": settings.text or "none",
|
||||
}
|
||||
)
|
||||
return pipe
|
||||
|
||||
|
||||
def _source(s: Settings):
|
||||
if s.source in (None, "none"):
|
||||
return None
|
||||
if s.source == "mss":
|
||||
from .backends.capture_mss import MSSFrameSource
|
||||
|
||||
return MSSFrameSource(interval=s.capture_interval)
|
||||
from .backends.mock import MockFrameSource
|
||||
|
||||
return MockFrameSource(interval=s.capture_interval)
|
||||
|
||||
|
||||
def _vision(s: Settings):
|
||||
if s.vision in (None, "none"):
|
||||
return None
|
||||
if s.vision == "claude":
|
||||
from .backends.claude import ClaudeVision
|
||||
|
||||
return ClaudeVision(model=s.anthropic_model)
|
||||
from .backends.mock import MockVision
|
||||
|
||||
return MockVision()
|
||||
|
||||
|
||||
def _brain(s: Settings):
|
||||
if s.brain == "claude":
|
||||
from .backends.claude import ClaudeBrain
|
||||
|
||||
return ClaudeBrain(model=s.anthropic_model)
|
||||
from .backends.mock import MockBrain
|
||||
|
||||
return MockBrain()
|
||||
|
||||
|
||||
def _stt(s: Settings):
|
||||
if s.stt in (None, "none"):
|
||||
return None
|
||||
if s.stt == "whisper":
|
||||
from .backends.whisper import WhisperSTT
|
||||
|
||||
return WhisperSTT()
|
||||
from .backends.mock import MockSTT
|
||||
|
||||
return MockSTT()
|
||||
|
||||
|
||||
def _tts(s: Settings):
|
||||
if s.tts in (None, "none"):
|
||||
return None
|
||||
if s.tts == "melo":
|
||||
from .backends.melo import MeloTTS
|
||||
|
||||
return MeloTTS()
|
||||
from .backends.mock import MockTTS
|
||||
|
||||
return MockTTS()
|
||||
|
||||
|
||||
def _text(s: Settings):
|
||||
if s.text in (None, "none"):
|
||||
return None
|
||||
raise NotImplementedError("discord text channel backend not implemented yet")
|
||||
137
wsai/interfaces.py
Normal file
137
wsai/interfaces.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Core data types and component interfaces for the watch-screen AI.
|
||||
|
||||
The whole system is a small pipeline:
|
||||
|
||||
FrameSource --frames--> VisionBackend --observations--> [SharedScreenContext]
|
||||
|
|
||||
SpeechToText / TextInput --utterances--> Brain <----------------/
|
||||
|
|
||||
v
|
||||
TextToSpeech / TextOutput
|
||||
|
||||
Every stage is a Protocol so a concrete backend (mock, local GPU, cloud API,
|
||||
discord web capture, ...) can be swapped in from config without touching the
|
||||
orchestrator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncIterator, Protocol, runtime_checkable
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Data that flows through the pipeline
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
"""A single captured image of the shared screen."""
|
||||
|
||||
# Raw encoded image bytes (PNG/JPEG). Kept as bytes so any backend can
|
||||
# decode it however it likes and so it is trivial to base64 for a cloud API.
|
||||
data: bytes
|
||||
width: int
|
||||
height: int
|
||||
# Monotonic capture timestamp in seconds.
|
||||
ts: float
|
||||
mime: str = "image/png"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenObservation:
|
||||
"""What the vision backend understood from a Frame."""
|
||||
|
||||
text: str
|
||||
ts: float
|
||||
# Optional structured hints (e.g. detected app, code language, error text).
|
||||
tags: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Utterance:
|
||||
"""Something the user said (voice→text) or typed."""
|
||||
|
||||
text: str
|
||||
ts: float
|
||||
source: str = "voice" # "voice" | "text"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reply:
|
||||
"""The AI's response, ready to be spoken and/or shown."""
|
||||
|
||||
text: str
|
||||
ts: float
|
||||
usage: dict | None = None # Claude token usage for this reply, when known
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Component interfaces
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@runtime_checkable
|
||||
class FrameSource(Protocol):
|
||||
"""Produces frames of the shared screen."""
|
||||
|
||||
async def frames(self) -> AsyncIterator[Frame]:
|
||||
"""Yield frames until cancelled. Cadence is up to the implementation."""
|
||||
...
|
||||
|
||||
async def aclose(self) -> None:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VisionBackend(Protocol):
|
||||
"""Turns a Frame into a text description of what is on screen."""
|
||||
|
||||
async def describe(self, frame: Frame, hint: str | None = None) -> ScreenObservation:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SpeechToText(Protocol):
|
||||
"""Streams user utterances from the microphone (or a mock source)."""
|
||||
|
||||
async def utterances(self) -> AsyncIterator[Utterance]:
|
||||
...
|
||||
|
||||
async def aclose(self) -> None:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TextToSpeech(Protocol):
|
||||
"""Speaks a reply out loud."""
|
||||
|
||||
async def speak(self, reply: Reply) -> None:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Brain(Protocol):
|
||||
"""The conversational LLM. Given the latest screen context, the user's
|
||||
message and the running history, produce a reply."""
|
||||
|
||||
async def respond(
|
||||
self,
|
||||
user_text: str,
|
||||
screen: ScreenObservation | None,
|
||||
history: list[tuple[str, str]],
|
||||
) -> Reply:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TextChannel(Protocol):
|
||||
"""Optional text I/O (e.g. a Discord channel) that mirrors the voice loop."""
|
||||
|
||||
async def messages(self) -> AsyncIterator[Utterance]:
|
||||
...
|
||||
|
||||
async def send(self, reply: Reply) -> None:
|
||||
...
|
||||
|
||||
async def aclose(self) -> None:
|
||||
...
|
||||
297
wsai/monitor.py
Normal file
297
wsai/monitor.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Telemetry hub for the live status dashboard.
|
||||
|
||||
The pipeline is a chain of steps (heard -> screen context -> brain -> speak).
|
||||
This module records, for every conversation turn, *what happened at each step*
|
||||
and *how long it took*, plus a rolling status header and any errors. The
|
||||
dashboard (``wsai/dashboard.py``) reads a snapshot and subscribes for live
|
||||
push updates.
|
||||
|
||||
Design notes:
|
||||
* Pure stdlib, no deps — matches the project's "core has no third-party deps".
|
||||
* Thread-safe. The pipeline mutates it from the asyncio loop; the HTTP server
|
||||
reads/subscribes from its own threads. A single lock guards everything.
|
||||
* A Monitor with zero subscribers is essentially free, so the pipeline can
|
||||
always hold one (no separate no-op path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _now_wall() -> float:
|
||||
# Wall-clock seconds for human-readable timestamps on the page.
|
||||
return time.time()
|
||||
|
||||
|
||||
def _now_mono() -> float:
|
||||
# Monotonic seconds for measuring durations (immune to clock jumps).
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
class Step:
|
||||
"""One timed stage inside a turn (e.g. "두뇌"). Used as an async context
|
||||
manager so it can wrap an ``await`` and record ok/error + elapsed ms."""
|
||||
|
||||
def __init__(self, turn: "Turn", name: str) -> None:
|
||||
self.turn = turn
|
||||
self.name = name
|
||||
self.ok: bool | None = None
|
||||
self.ms: float = 0.0
|
||||
self.detail: str = ""
|
||||
self.error: str = ""
|
||||
self._t0 = 0.0
|
||||
|
||||
async def __aenter__(self) -> "Step":
|
||||
self._t0 = _now_mono()
|
||||
self.turn._steps.append(self)
|
||||
self.turn._touch()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
self.ms = (_now_mono() - self._t0) * 1000.0
|
||||
if exc is not None:
|
||||
self.ok = False
|
||||
self.error = f"{exc_type.__name__}: {exc}"
|
||||
else:
|
||||
self.ok = True
|
||||
self.turn._touch()
|
||||
return False # never swallow: the pipeline/TaskGroup must still see it
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"ok": self.ok,
|
||||
"ms": round(self.ms, 1),
|
||||
"detail": self.detail,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
class Turn:
|
||||
"""One user utterance and everything the AI did in response."""
|
||||
|
||||
def __init__(self, monitor: "Monitor", turn_id: int, source: str) -> None:
|
||||
self._monitor = monitor
|
||||
self.id = turn_id
|
||||
self.source = source
|
||||
self.speaker = "" # who spoke (Discord display name), when known
|
||||
self.guild = "" # server name, when known (for 서버별 필터)
|
||||
self.channel = "" # voice channel name, when known (for 채널별 필터)
|
||||
self.wall = _now_wall()
|
||||
self._t0 = _now_mono()
|
||||
self.heard_text = ""
|
||||
self.thought_text = ""
|
||||
self.reply_text = ""
|
||||
self.status = "active" # active | ok | error
|
||||
self.error = ""
|
||||
self.total_ms = 0.0
|
||||
self._steps: list[Step] = []
|
||||
self._error_logged = False # count this turn's failure at most once
|
||||
|
||||
# -- recording API (called from the pipeline) ------------------------- #
|
||||
def heard(self, text: str) -> None:
|
||||
self.heard_text = text
|
||||
self._touch()
|
||||
|
||||
def thought(self, text: str) -> None:
|
||||
self.thought_text = text
|
||||
self._touch()
|
||||
|
||||
def replied(self, text: str) -> None:
|
||||
self.reply_text = text
|
||||
self._touch()
|
||||
|
||||
def step(self, name: str) -> Step:
|
||||
return Step(self, name)
|
||||
|
||||
def finish(self, error: str = "") -> None:
|
||||
self.total_ms = (_now_mono() - self._t0) * 1000.0
|
||||
if error:
|
||||
self.status = "error"
|
||||
self.error = error
|
||||
elif any(s.ok is False for s in self._steps):
|
||||
self.status = "error"
|
||||
if not self.error:
|
||||
failed = next((s for s in self._steps if s.ok is False), None)
|
||||
self.error = (failed.error if failed else "") or "step failed"
|
||||
else:
|
||||
self.status = "ok"
|
||||
# A turn that ended in error must be reflected in errors_total. That
|
||||
# counter is driven by error-level log events on BOTH the server
|
||||
# (Monitor.log) and the browser (dashboard SSE handler), so emit one
|
||||
# log event here rather than bumping a counter the client won't mirror.
|
||||
# Guarded so the repeated _touch()/finish() calls can't double-count.
|
||||
if self.status == "error" and not self._error_logged:
|
||||
self._error_logged = True
|
||||
self._monitor.log("error", f"대화 #{self.id} 실패: {self.error}")
|
||||
self._touch()
|
||||
|
||||
# -- internal --------------------------------------------------------- #
|
||||
def _touch(self) -> None:
|
||||
self._monitor._publish(self)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"source": self.source,
|
||||
"speaker": self.speaker,
|
||||
"guild": self.guild,
|
||||
"channel": self.channel,
|
||||
"wall": self.wall,
|
||||
"heard": self.heard_text,
|
||||
"thought": self.thought_text,
|
||||
"reply": self.reply_text,
|
||||
"status": self.status,
|
||||
"error": self.error,
|
||||
"total_ms": round(self.total_ms, 1),
|
||||
"steps": [s.to_dict() for s in self._steps],
|
||||
}
|
||||
|
||||
|
||||
class Monitor:
|
||||
"""Rolling record of turns + status, with a pub/sub for live updates."""
|
||||
|
||||
def __init__(self, keep: int = 60) -> None:
|
||||
self._turns: deque[Turn] = deque(maxlen=keep)
|
||||
# Keep a long event tail so the terminal panel can show the log from
|
||||
# (voice-server) start, not just the last few lines.
|
||||
self._events: deque[dict[str, Any]] = deque(maxlen=2000)
|
||||
self._status: dict[str, Any] = {
|
||||
"running": False,
|
||||
"listening": False,
|
||||
"started_wall": _now_wall(),
|
||||
"components": {},
|
||||
"turns_total": 0,
|
||||
"errors_total": 0,
|
||||
# Claude usage since server start (this bot's own consumption).
|
||||
"claude_requests": 0,
|
||||
"claude_input_tokens": 0,
|
||||
"claude_output_tokens": 0,
|
||||
}
|
||||
self._lock = threading.Lock()
|
||||
self._subs: list["queue.Queue[str]"] = []
|
||||
self._id = 0
|
||||
self._event_id = 0
|
||||
|
||||
# -- status ----------------------------------------------------------- #
|
||||
def set_status(self, **kw: Any) -> None:
|
||||
with self._lock:
|
||||
self._status.update(kw)
|
||||
self._broadcast({"type": "status", "status": self.status_snapshot()})
|
||||
|
||||
def set_components(self, components: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self._status["components"] = components
|
||||
self._broadcast({"type": "status", "status": self.status_snapshot()})
|
||||
|
||||
def status_snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
s = dict(self._status)
|
||||
s["uptime_s"] = round(_now_wall() - s["started_wall"], 1)
|
||||
return s
|
||||
|
||||
def add_claude_usage(self, input_tokens: int, output_tokens: int) -> None:
|
||||
"""Accumulate one Claude call's token usage for the dashboard."""
|
||||
with self._lock:
|
||||
self._status["claude_requests"] += 1
|
||||
self._status["claude_input_tokens"] += int(input_tokens or 0)
|
||||
self._status["claude_output_tokens"] += int(output_tokens or 0)
|
||||
self._broadcast({"type": "status", "status": self.status_snapshot()})
|
||||
|
||||
def log(self, level: str, message: str) -> None:
|
||||
"""A free-form lifecycle/error line (startup, disconnect, crash…)."""
|
||||
with self._lock:
|
||||
self._event_id += 1
|
||||
evt = {"type": "log", "id": self._event_id, "level": level,
|
||||
"message": message, "wall": _now_wall()}
|
||||
self._events.append(evt)
|
||||
if level == "error":
|
||||
self._status["errors_total"] += 1
|
||||
self._broadcast(evt)
|
||||
|
||||
def delete_event(self, event_id: int) -> bool:
|
||||
"""Remove one log line by id (dashboard per-line '삭제')."""
|
||||
found = False
|
||||
with self._lock: # _broadcast re-locks, so must run outside this block
|
||||
for e in list(self._events):
|
||||
if e.get("id") == event_id:
|
||||
self._events.remove(e)
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
self._broadcast({"type": "log_deleted", "id": event_id})
|
||||
return found
|
||||
|
||||
def edit_event(self, event_id: int, message: str) -> bool:
|
||||
"""Edit one log line's text by id (dashboard per-line '수정')."""
|
||||
found = False
|
||||
with self._lock: # _broadcast re-locks, so must run outside this block
|
||||
for e in self._events:
|
||||
if e.get("id") == event_id:
|
||||
e["message"] = message
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
self._broadcast({"type": "log_edited", "id": event_id, "message": message})
|
||||
return found
|
||||
|
||||
def clear_events(self) -> None:
|
||||
"""Wipe the event/error log (dashboard '로그 삭제'). Broadcasts a reset so
|
||||
every connected page clears its terminal panel too."""
|
||||
with self._lock:
|
||||
self._events.clear()
|
||||
self._broadcast({"type": "logs_cleared"})
|
||||
|
||||
# -- turns ------------------------------------------------------------ #
|
||||
def turn(self, source: str = "voice") -> Turn:
|
||||
with self._lock:
|
||||
self._id += 1
|
||||
self._status["turns_total"] += 1
|
||||
t = Turn(self, self._id, source)
|
||||
self._turns.append(t)
|
||||
self._publish(t)
|
||||
return t
|
||||
|
||||
def _publish(self, t: Turn) -> None:
|
||||
# errors_total is bumped once when the turn transitions to error, inside
|
||||
# Turn.finish() (via a log event), so this only streams the turn state.
|
||||
self._broadcast({"type": "turn", "turn": t.to_dict()})
|
||||
|
||||
# -- snapshot / subscribe (read side, HTTP threads) ------------------- #
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
turns = [t.to_dict() for t in self._turns]
|
||||
events = list(self._events)
|
||||
return {
|
||||
"status": self.status_snapshot(),
|
||||
"turns": turns,
|
||||
"events": events,
|
||||
}
|
||||
|
||||
def subscribe(self) -> "queue.Queue[str]":
|
||||
q: "queue.Queue[str]" = queue.Queue(maxsize=256)
|
||||
with self._lock:
|
||||
self._subs.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: "queue.Queue[str]") -> None:
|
||||
with self._lock:
|
||||
if q in self._subs:
|
||||
self._subs.remove(q)
|
||||
|
||||
def _broadcast(self, event: dict[str, Any]) -> None:
|
||||
data = json.dumps(event, ensure_ascii=False)
|
||||
with self._lock:
|
||||
subs = list(self._subs)
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except queue.Full:
|
||||
# Slow client: drop it rather than block the pipeline.
|
||||
self.unsubscribe(q)
|
||||
196
wsai/pipeline.py
Normal file
196
wsai/pipeline.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Orchestrator: wires the perception loop and the conversation loop together."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from .interfaces import (
|
||||
Brain,
|
||||
FrameSource,
|
||||
Reply,
|
||||
SpeechToText,
|
||||
TextChannel,
|
||||
TextToSpeech,
|
||||
Utterance,
|
||||
VisionBackend,
|
||||
)
|
||||
from .monitor import Monitor
|
||||
from .state import SharedScreenContext
|
||||
|
||||
log = logging.getLogger("wsai.pipeline")
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Runs two concurrent loops:
|
||||
|
||||
* perception: FrameSource -> VisionBackend -> SharedScreenContext
|
||||
* conversation: (SpeechToText | TextChannel) -> Brain -> (TextToSpeech | TextChannel)
|
||||
|
||||
Any half can be omitted. With no source/vision it runs eyes-free as a pure
|
||||
voice loop (STT -> Brain -> TTS); with no stt/tts it runs text-only; with no
|
||||
conversation it is a headless "just watch" configuration.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source: FrameSource | None = None,
|
||||
vision: VisionBackend | None = None,
|
||||
brain: Brain,
|
||||
stt: SpeechToText | None = None,
|
||||
tts: TextToSpeech | None = None,
|
||||
text_channel: TextChannel | None = None,
|
||||
history_turns: int = 12,
|
||||
monitor: Monitor | None = None,
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.vision = vision
|
||||
self.brain = brain
|
||||
self.stt = stt
|
||||
self.tts = tts
|
||||
self.text_channel = text_channel
|
||||
self.monitor = monitor
|
||||
self.context = SharedScreenContext()
|
||||
self._history: list[tuple[str, str]] = []
|
||||
self._history_turns = history_turns
|
||||
|
||||
# -- perception -------------------------------------------------------- #
|
||||
async def _perceive(self) -> None:
|
||||
if self.source is None or self.vision is None:
|
||||
return # eyes-free (voice-only) configuration
|
||||
async for frame in self.source.frames():
|
||||
try:
|
||||
obs = await self.vision.describe(frame)
|
||||
except Exception as exc: # a single bad frame must not kill the loop
|
||||
log.exception("vision.describe failed")
|
||||
if self.monitor is not None:
|
||||
self.monitor.log("error", f"화면 이해 실패: {exc}")
|
||||
continue
|
||||
await self.context.update(obs)
|
||||
log.debug("screen: %s", obs.text[:120])
|
||||
|
||||
# -- conversation ------------------------------------------------------ #
|
||||
async def _handle(self, utt: Utterance) -> None:
|
||||
if self.monitor is None:
|
||||
screen = await self.context.latest()
|
||||
reply = await self.brain.respond(utt.text, screen, self._history)
|
||||
self._remember(utt.text, reply.text)
|
||||
await self._emit(reply)
|
||||
return
|
||||
|
||||
# Same work, but each stage is timed and streamed to the dashboard so a
|
||||
# viewer can see what was heard, what the brain answered, how long each
|
||||
# step took, and whether anything errored.
|
||||
turn = self.monitor.turn(source=utt.source)
|
||||
turn.heard(utt.text)
|
||||
try:
|
||||
async with turn.step("화면 맥락"):
|
||||
screen = await self.context.latest()
|
||||
async with turn.step("두뇌(생각)"):
|
||||
reply = await self.brain.respond(utt.text, screen, self._history)
|
||||
turn.replied(reply.text)
|
||||
self._remember(utt.text, reply.text)
|
||||
async with turn.step("응답(TTS/전송)"):
|
||||
await self._emit(reply)
|
||||
except Exception as exc:
|
||||
# finish() records the error and emits the single error-level log
|
||||
# event that bumps errors_total, so don't log the same failure twice.
|
||||
turn.finish(error=f"{type(exc).__name__}: {exc}")
|
||||
raise
|
||||
else:
|
||||
turn.finish()
|
||||
|
||||
def _remember(self, user: str, ai: str) -> None:
|
||||
self._history.append((user, ai))
|
||||
if len(self._history) > self._history_turns:
|
||||
self._history = self._history[-self._history_turns :]
|
||||
|
||||
async def _emit(self, reply: Reply) -> None:
|
||||
tasks = []
|
||||
if self.tts is not None:
|
||||
tasks.append(self.tts.speak(reply))
|
||||
if self.text_channel is not None:
|
||||
tasks.append(self.text_channel.send(reply))
|
||||
if not tasks:
|
||||
log.info("AI: %s", reply.text)
|
||||
else:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def _listen_voice(self) -> None:
|
||||
if self.stt is None:
|
||||
return
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(listening=True)
|
||||
self.monitor.log("info", "음성 수신 시작 — 발화 대기 중")
|
||||
try:
|
||||
async for utt in self.stt.utterances():
|
||||
await self._handle(utt)
|
||||
finally:
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(listening=False)
|
||||
|
||||
async def _listen_text(self) -> None:
|
||||
if self.text_channel is None:
|
||||
return
|
||||
async for utt in self.text_channel.messages():
|
||||
await self._handle(utt)
|
||||
|
||||
# -- lifecycle --------------------------------------------------------- #
|
||||
async def _prewarm(self) -> None:
|
||||
"""Load slow-to-start backends before the loops accept input.
|
||||
|
||||
Real STT/TTS backends (faster-whisper, MeloTTS) load a model into a
|
||||
persistent worker on first use — several seconds on CPU. Warming them
|
||||
here means the first real utterance is answered warm (~1s) instead of
|
||||
paying the cold model load mid-conversation."""
|
||||
warmers = []
|
||||
for comp, name in ((self.tts, "tts"), (self.stt, "stt")):
|
||||
warmup = getattr(comp, "warmup", None)
|
||||
if callable(warmup):
|
||||
warmers.append((name, warmup))
|
||||
if not warmers:
|
||||
return
|
||||
for name, warmup in warmers:
|
||||
try:
|
||||
await warmup()
|
||||
except Exception as exc: # a warm failure must not abort startup
|
||||
log.warning("prewarm %s failed: %s", name, exc)
|
||||
if self.monitor is not None:
|
||||
self.monitor.log("error", f"{name} 예열 실패: {exc}")
|
||||
|
||||
async def run(self) -> None:
|
||||
# A TaskGroup (not bare gather) so that if ONE loop raises, the others
|
||||
# are cancelled and awaited before teardown. With plain gather the
|
||||
# failing loop propagated while the siblings kept running detached, and
|
||||
# aclose() in the finally then closed a source/stt out from under a
|
||||
# still-live loop (close-during-use).
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(running=True)
|
||||
self.monitor.log("info", "파이프라인 시작")
|
||||
await self._prewarm()
|
||||
try:
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(self._perceive())
|
||||
tg.create_task(self._listen_voice())
|
||||
tg.create_task(self._listen_text())
|
||||
except* Exception as eg:
|
||||
if self.monitor is not None:
|
||||
for exc in eg.exceptions:
|
||||
self.monitor.log("error", f"루프 예외: {type(exc).__name__}: {exc}")
|
||||
raise
|
||||
finally:
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(running=False, listening=False)
|
||||
self.monitor.log("info", "파이프라인 종료")
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
# tts is included because a real TTS (e.g. MeloTTS) owns a worker
|
||||
# subprocess that must be torn down; mock backends have no aclose.
|
||||
for closer in (self.source, self.stt, self.text_channel, self.tts):
|
||||
if closer is not None and hasattr(closer, "aclose"):
|
||||
try:
|
||||
await closer.aclose()
|
||||
except Exception:
|
||||
log.exception("error closing %s", closer)
|
||||
54
wsai/prompt_store.py
Normal file
54
wsai/prompt_store.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Persisted, live-editable bot persona (the brain's system prompt).
|
||||
|
||||
The dashboard lets the user view and edit the bot's system prompt at runtime.
|
||||
The brain reads the current persona on every turn, so an edit takes effect on
|
||||
the next reply with no restart. The text is persisted to disk so it survives a
|
||||
restart; when no override file exists the caller's default
|
||||
(``ClaudeBrain.PERSONA``) is used.
|
||||
|
||||
Path: ``$WSAI_PROMPT_PATH`` or ``~/.config/wsai/persona.txt`` (disk, never a
|
||||
RAM-backed tmpfs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
override = os.environ.get("WSAI_PROMPT_PATH")
|
||||
return Path(override) if override else (Path.home() / ".config" / "wsai" / "persona.txt")
|
||||
|
||||
|
||||
def get_persona(default: str) -> str:
|
||||
"""Return the saved persona override, or ``default`` if none is set."""
|
||||
try:
|
||||
with _LOCK:
|
||||
text = _path().read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return default
|
||||
return text if text.strip() else default
|
||||
|
||||
|
||||
def set_persona(text: str) -> None:
|
||||
"""Persist a new persona. Blank text clears the override (reverts to default)."""
|
||||
p = _path()
|
||||
with _LOCK:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if text and text.strip():
|
||||
p.write_text(text, encoding="utf-8")
|
||||
else:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def is_overridden() -> bool:
|
||||
"""True when a non-empty override file is in effect."""
|
||||
try:
|
||||
with _LOCK:
|
||||
return bool(_path().read_text(encoding="utf-8").strip())
|
||||
except OSError:
|
||||
return False
|
||||
34
wsai/state.py
Normal file
34
wsai/state.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Shared, thread/async-safe screen context.
|
||||
|
||||
The perception loop keeps writing the latest ScreenObservation here; the
|
||||
conversation loop reads it when the user says something. We only keep the most
|
||||
recent observation plus a short ring buffer of recent ones so the Brain can
|
||||
notice "the screen changed" without us re-sending every frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
|
||||
from .interfaces import ScreenObservation
|
||||
|
||||
|
||||
class SharedScreenContext:
|
||||
def __init__(self, history: int = 8) -> None:
|
||||
self._latest: ScreenObservation | None = None
|
||||
self._recent: deque[ScreenObservation] = deque(maxlen=history)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def update(self, obs: ScreenObservation) -> None:
|
||||
async with self._lock:
|
||||
self._latest = obs
|
||||
self._recent.append(obs)
|
||||
|
||||
async def latest(self) -> ScreenObservation | None:
|
||||
async with self._lock:
|
||||
return self._latest
|
||||
|
||||
async def recent(self) -> list[ScreenObservation]:
|
||||
async with self._lock:
|
||||
return list(self._recent)
|
||||
Reference in New Issue
Block a user