Compare commits

...

46 Commits

Author SHA1 Message Date
EJClaw
f1f059a018 feat(dashboard): show Claude usage (tokens + requests) since server start
ClaudeBrain now returns per-reply token usage (Reply.usage from the API
response), the dashboard accumulates it (monitor.add_claude_usage), and the
header shows a "클로드 토큰" stat (input+output total, with a tooltip breaking
down input/output tokens and request count).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 16:40:25 +09:00
EJClaw
5d9161982f feat(brain): rewrite system persona — Discord real-time voice AI, sectioned
Redefines the bot as "디스코드를 이용해 실시간으로 대화하는 AI 인공지능" and drops
all screen-share wording. Organizes the one-line prompt into sections (역할·언어·
답변방식·대화태도·안전/사실성·감정표현·정체성). Adds: always-Korean-unless-asked,
길이 정량화(한두 문장·10초), 되묻기/침묵 무시, 불확실·최신 정보는 "확인 필요",
URL·숫자·코드 풀어 읽기. Keeps the [감정] tag section (required by the emotion TTS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 16:35:11 +09:00
EJClaw
be4bc87edf feat(dashboard): conversation log filter by time/user/server/channel/content
Adds the requested multi-dimension log search. Turns now carry speaker, guild
and channel (the bot sends X-User/Guild/Channel-Name on the voice-turn POST), and
a filter bar above the conversation feed narrows by 시간(최근 N분)·유저·서버·
채널·내용. The event/error panel keeps its text+level search.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 12:15:11 +09:00
EJClaw
50838ef602 docs(readme): document the operational voice-server, emotion TTS, and dashboard
Records what's now built beyond the original plan: real GPU STT/brain/TTS via
the voice-server, bracketed-emotion TTS (pitch/speed), and the dashboard's
prompt editing, bot control bar, whitelist/blacklist, 3-row turns, and log dock,
plus the dashboard<->bot control plane.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 12:07:04 +09:00
EJClaw
441ab4831f feat(bot+dashboard): whitelist/blacklist listen filter (users + roles)
- Per-guild listen filter stored in the control plane (BotControl) with
  GET/POST /api/bot/lists; the filter also rides along in the bot report
  response so the bot always has the latest config.
- 화이트리스트/블랙리스트 popup: search the guild's members OR roles (type
  selector), add/remove to white/black, save. Whitelist = listen to only those
  (empty = everyone); blacklist = exclude. Reuses the shared 뒤로가기 modal.
- Bot reports guild roles + known members for the search UI, and filters
  incoming audio via a pure, unit-tested isAllowed() (dave/filter.mjs):
  blacklist always excludes; a non-empty whitelist restricts; else everyone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 12:06:03 +09:00
EJClaw
7eb590b729 fix(bot): tear down existing voice connection before re-joining
joinChannel reused a same-guild connection and re-subscribed a new player and
receiver each time, stacking duplicate speaking listeners (→ duplicate voice
turns) and error handlers. Now it no-ops if already in the target channel and
otherwise leaves the current connection first, so channel switches are clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 12:00:51 +09:00
EJClaw
d3cf4e01b5 feat(bot+dashboard): bot info, server/voice-channel picker, participants, speaker
Adds a dashboard<->bot control plane (bot pushes state + polls commands, keeping
the bot's single outbound-HTTP direction):
- New bot_control.BotControl + endpoints: GET /api/bot/state, /api/bot/commands;
  POST /api/bot/report, /api/bot/select.
- Dashboard header bar: bot identity/connection, server dropdown (top "없음"),
  voice-channel dropdown (top "없음"), and live participant list.
- Turns record who spoke (Turn.speaker, via X-User-Name on the voice-turn POST).
- dave/bot.mjs: reports identity/guilds/voice-channels/members, polls join/leave
  commands and joins dynamically, and sends the speaker's display name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 11:43:48 +09:00
EJClaw
6a2865d899 feat(dashboard): VSCode-style log dock, 3-row turns, log search/edit/delete
- Bottom-docked collapsible terminal log panel (open/close), retains the event
  log from voice-server start (event tail 200→2000).
- Log search box + level filter (전체/오류/경고/정보); per-line 삭제/수정 and
  전체 삭제, backed by new monitor event ids and /api/logs/{clear,delete,edit}.
- Turns now show 들음 / 생각 / 답변 three rows; 생각 surfaces the emotion-tone
  plan the bot chose (and the [잡음] decision), via a new Turn.thought field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 11:35:11 +09:00
EJClaw
df07224feb feat(dashboard): live-editable bot prompt via popup (view/edit/save)
Adds a persisted, runtime-editable system prompt. The brain reads the persona
on every turn (prompt_store.get_persona), so a dashboard edit applies to the
next reply with no restart; blank clears the override back to the built-in
PERSONA. New endpoints GET/POST /api/prompt, and a reusable modal popup
(뒤로가기 + 수정/저장) that later white/blacklist features will share.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 11:20:57 +09:00
EJClaw
1cb7658290 merge: unify Gitea main README history into code history 2026-08-22 11:13:23 +09:00
EJClaw
87b77f9997 fix(voice): stop dashboard from mangling the leading [감정] tag
The dashboard voice-turn path ran _speech_text() before MeloTTS.synth, which
rewrote a leading "[힘차게] 안녕!" into "힘차게, 안녕!" — reading the first
emotion aloud and destroying the tag before the TTS emotion parser could use it.
Make _speech_text() a pass-through so every emotion tag (including the first)
reaches synth intact and shapes pitch/speed instead of being spoken. Adds a
regression test covering the leading-tag case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 10:18:54 +09:00
EJClaw
4db73bf69f feat(voice): express [감정] tags via pitch/speed instead of speaking them
Emotion tags now steer delivery rather than being read aloud. parse_segments()
splits a reply on [감정] tags: a recognised emotion word switches the pitch and
speed of the text that follows (and is dropped), while a non-emotion bracket
(e.g. [1번]) keeps its inner words as spoken content. Emotions can change
mid-reply, so a single turn is synthesised as several pitch-shifted segments and
concatenated in the melo worker (librosa pitch_shift, warmed at startup).

The emotion vocabulary is grounded in Azure Neural TTS speaking styles plus
Ekman's basic emotions, with Korean synonyms. The brain persona is updated to
emit inline tags from that set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 10:14:49 +09:00
EJClaw
f585ed7b76 feat(voice): bracketed emotion tags, [잡음] for noise, spoken emotion, concise replies
- Brain persona now prefixes every reply with one bracketed emotion tag
  (e.g. [반가움], [궁금]) and keeps replies to one or two short sentences.
- Empty/unrecognised audio (silence/noise) is reported as reply "[잡음]" with
  no TTS playback instead of an empty reply.
- TTS speaks the bracketed emotion too: "[힘차게] 안녕!" is synthesised as
  "힘차게, 안녕!" via a leading-tag -> spoken-word transform.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 01:44:25 +09:00
EJClaw
2c4c9ad82c feat(bot): reach host voice-server + throttle DAVE decrypt error bursts
- Log voice connection errors instead of letting them surface silently.
- Collapse bursty repeated receive-stream errors (DAVE E2EE group-transition
  decrypt failures) into one line + a suppressed-count summary, so a member
  joining/leaving no longer floods the log.

Deploy: voice-server now runs as the wsai-voice.service user unit (STT+Claude
Haiku brain+TTS on GPU); the bot container reaches it via
host.docker.internal:8787.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-21 23:22:21 +09:00
EJClaw
356d1128fa fix(voice): stop backtick TTS crash and actually count error turns
Claude replies with markdown/backticks by default; MeloTTS's Korean text
normaliser has no entry for '`' and dies with KeyError: '`', so any reply
mentioning a command/code block crashed the whole voice turn (500 on
/api/voice-turn). Fix at the shared synth() choke point with
normalize_for_speech(), which flattens code fences/inline code/links/markdown
and guarantees no backtick reaches the worker — covering both the dashboard
voice turn and the Discord speak() bridge. Also add a PERSONA line asking the
model to avoid markdown (belt-and-suspenders; the code strip is the real fix).

errors_total never moved for turn-level failures: it was only bumped by
log("error") events, and the dashboard voice path calls turn.finish(error=...)
without logging. Emit one error-level log event from Turn.finish() when a turn
ends in error, so both the server counter and the browser SSE mirror stay
consistent, guarded to count at most once. Drop the now-redundant pipeline
log("error") to avoid double counting and remove the dead _publish stub.

Verified: raw backtick -> worker KeyError '`' reproduced; after fix real
MeloTTS synth of a backtick+fenced reply succeeds; /api/voice-turn returns 200
with a wav body on a backtick reply and errors_total stays 0, and an induced
synth failure returns 500 with errors_total incrementing to exactly 1. Full
suite 18 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 23:29:13 +09:00
EJClaw
ee6f6b7f55 fix(brain): ride out transient Claude 529 overloads
A single Claude 529 Overloaded dropped the voice turn straight to the apology
fallback. The anthropic SDK retries >=500/429 but only twice by default, which
a busy window can outlast. Raise max_retries (WSAI_BRAIN_MAX_RETRIES, default 4)
so transient overloads recover silently, and give overloads their own spoken
fallback ("서버가 붐벼서...") distinct from generic failures. Kept modest so a
sustained outage still fails fast instead of leaving the bot silent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 23:09:36 +09:00
EJClaw
575ac2949a feat(voice): real Claude brain in the Discord loop (think, not echo)
The voice loop echoed the recognised text. Wire the real brain: the Discord
voice-turn now runs STT -> ClaudeBrain.respond (with rolling conversation
history) -> TTS, so the bot actually thinks and answers. --voice-server builds
the brain by default (WSAI_BRAIN=claude, WSAI_BRAIN_MODEL overridable) and
gracefully falls back to echo if anthropic/Claude auth is unavailable. A brain
error speaks a short apology instead of killing the loop.

Verified end-to-end: an utterance wav returns X-Heard plus a distinct Claude
X-Reply and a synthesised reply wav on device=cuda. 12 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 23:03:55 +09:00
EJClaw
95e2d4472b fix(bot): tolerate slow DAVE join + never crash on a receive-stream error
- Raise the voice-Ready ceiling 20s->40s: the DAVE/MLS handshake cycles
  signalling<->connecting and can take ~25s, so 20s spuriously failed the join.
- Handle AudioReceiveStream 'error' (e.g. a DAVE decrypt/UDP GenericFailure on
  one packet): log and free the speaker slot instead of letting the unhandled
  'error' event crash the whole bot process.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 22:05:57 +09:00
EJClaw
0f94245d5f feat(voice): real Discord listen+speak loop (STT->echo->TTS)
The bot (dave/bot.mjs) previously only joined the channel and counted audio
frames — it never fed STT or spoke back. Wire the real loop:

- Node bot: buffer each speaker's Opus->PCM utterance until AfterSilence,
  wrap as WAV, POST to the Python voice-turn endpoint, then play the returned
  reply wav into the channel via an AudioPlayer (ffmpeg->Opus). Skips its own
  audio, dedupes overlapping subscriptions, and ignores sub-0.35s noise.
- Python: new `python -m wsai --voice-server` serves /api/voice-turn — decode
  the uploaded utterance, GPU faster-whisper STT, produce a reply (echo of what
  was heard for now), GPU MeloTTS synth, return the reply wav (recognised/reply
  text ride along as X-Heard/X-Reply headers). Both engines pre-warmed; turns
  show in the dashboard feed. MeloTTS.synth() extracted for direct wav reuse.

Echo mode verifies listening+speaking+GPU recognition entirely in Discord; the
Claude brain is the next slice. Verified the endpoint round-trip: utterance wav
-> correct Korean X-Heard/X-Reply + a WAVE reply on device=cuda. 12 tests pass,
node --check clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 21:59:01 +09:00
EJClaw
b9c929a73f feat(dashboard): browser STT recognition test on the GPU
The status page was view-only, so there was no way to actually verify Korean
recognition end-to-end. Add a live test: record from the mic (localhost/https)
or upload an audio file (works over LAN http, where browsers block getUserMedia),
POST it to a new /api/stt endpoint that ffmpeg-normalises the blob to 16 kHz
mono and runs the real GPU faster-whisper, then shows the recognised text +
latency + device. Results also land in the live turn feed.

The dashboard now optionally holds a WhisperSTT and drives it from a private
asyncio loop thread. New `python -m wsai --stt-test` serves the page with STT
enabled and pre-warms the GPU worker so the first recognition is instant.
WhisperSTT.resolved_device is exposed for the UI.

Verified: wav and browser-style webm/opus uploads both return the correct
Korean text on device=cuda in ~240-280ms. 12 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 21:49:03 +09:00
EJClaw
77d7cd8b56 perf(voice): warm up STT+TTS workers before signalling ready
The first CUDA inference pays a large lazy cost (kernel autotune/cudnn) —
~10s for a cold TTS synth — which would blow the voice loop's ~1s budget on
the very first reply. Each worker now runs one dummy inference (TTS: a short
phrase; STT: 1s of silence) after model load and before emitting "ready", so
"ready" means "hot". Warmup failures are logged and never block startup.

Verified: first real call after startup is now TTS ~238ms / STT ~189ms
(was ~11s cold for TTS). 12 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 21:06:23 +09:00
EJClaw
51811ad251 perf(voice): run STT+TTS on the GPU by default with CPU fallback
Both voice backends defaulted to CPU. Fix the "CUDA unavailable" gaps so
everything that benefits from the RTX 5050 uses it:

- MeloTTS venv had CPU-only torch (2.12.0+cpu) -> installed Blackwell-capable
  torch/torchaudio 2.11.0+cu128 (sm_120 verified with a real GPU matmul).
- faster-whisper CUDA loaded but transcribe() died with "libcublas.so.12 not
  found": installed nvidia-cublas-cu12 + nvidia-cudnn-cu12 into the whisper
  venv and inject those nvidia/*/lib dirs into the worker's LD_LIBRARY_PATH at
  spawn (the loader only honours it at exec).
- WSAI_WHISPER_DEVICE / WSAI_MELO_DEVICE now default to "auto": pick CUDA when
  present, else CPU, and each worker falls back to CPU if a CUDA load fails so
  the voice loop never dies on a GPU-less host.

Verified end-to-end through the real backend classes: both workers report
"ready on cuda"; steady-state STT ~170ms (was ~1350ms CPU), TTS ~4s first call
vs ~23s CPU. All 12 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 21:02:52 +09:00
EJClaw
63fcfb7ba2 feat(stt): real Korean STT via persistent faster-whisper worker
Step 3 (귀): add WhisperSTT + whisper_worker, a warm out-of-venv worker
mirroring the MeloTTS shape (whisper312 venv, small/int8 on CPU). transcribe()
closes the voice round trip (MeloTTS wav -> whisper text); utterances() turns an
injected audio_source into Utterances (Discord voice feed pending). Wired into
factory as WSAI_STT=whisper.

Also address the arbiter's TTS follow-ups:
- melo worker error handling: capture stderr (drained in a bounded background
  task so the pipe can't fill), surface the real failure cause, and defend
  against an empty/invalid ready line instead of dying on JSONDecodeError.
- pipeline pre-warm: load slow backends (warmup()) at startup so the first
  utterance is answered warm; a warmup failure is logged, not fatal.

Verified: real TTS->STT round trip recovers the sentence near-perfectly;
warm transcribe ~1.2s (CPU). 12 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 18:29:32 +09:00
EJClaw
6a138eff3a feat(tts): real Korean TTS via persistent MeloTTS worker
Adds a MeloTTS backend that runs the model in its own melo311 interpreter
as a long-lived worker (melo_worker.py), loaded once and fed synthesis
requests over a stdin/stdout JSON protocol. fd1 is split from fd2 in the
worker so MeloTTS's stdout progress chatter can't corrupt the protocol.
Each speak() writes a wav and hands the path to a pluggable sink (the
Discord voice step will swap in "play into the call"). factory wires
tts=melo; pipeline.aclose now also tears down the tts worker.

Verified (CPU): model load ~7.9s once, then a short reply synthesizes in
~0.86s (within the ~1s budget); wav is valid 44.1kHz PCM. GPU (cuda) is
selectable via WSAI_MELO_DEVICE for lower latency, pending GPU approval.
7 smoke tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 18:14:44 +09:00
EJClaw
4898192ae1 feat(brain): real Claude backend via Max OAuth token (auth_token + Claude Code system block)
Wires the deployment's Claude Max login (OAuth token from
$CLAUDE_CREDENTIALS_PATH) into ClaudeBrain/ClaudeVision. OAuth tokens
authenticate as Bearer (auth_token=), not x-api-key, and only answer
when the first system block is the Claude Code identity string, so the
real persona moves to a second system block. Token is re-read per
request so a host-side refresh is picked up without a restart. Falls
back to ANTHROPIC_API_KEY when set.

Verified: WSAI_BRAIN=claude returns a real Korean reply through the
factory; 7 smoke tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 18:05:18 +09:00
EJClaw
9bac6d170a fix(dashboard): stop infinite mock loop; add demo-mode warning banner
--dashboard defaulted to looping mock STT forever, so the status page
piled up thousands of fake "conversations" (all mock, 0ms, same reply)
that looked like real traffic. Now it plays 3 sample utterances then
idles; a loud 데모 모드 banner states the turns are mock samples, not
real STT/Brain/TTS. Continuous demo moved behind --dashboard-loop-demo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-16 23:06:30 +09:00
EJClaw
6b0755e1ff feat: live status dashboard for the voice loop
Add a stdlib-only observability site so you can open a browser and watch,
step by step: whether it is listening, what it heard, what the brain thought
and answered, how long each stage took, and whether anything errored.

- wsai/monitor.py: thread-safe telemetry hub (per-turn timed steps, status
  header, error log) with a pub/sub for live push.
- wsai/dashboard.py: stdlib http.server serving a self-contained page plus an
  SSE (/events) live stream; /api/state snapshot fallback.
- Pipeline emits step-by-step turn telemetry (화면 맥락 → 두뇌 → 응답) and
  listening/running status; optional monitor, so existing paths are untouched.
- `python -m wsai --dashboard` starts the site (0.0.0.0:8787, WSAI_DASHBOARD_PORT)
  and loops the mock voice demo so there is always live activity to watch.
- Tests cover turn recording, per-step timing, error marking, and live push.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-16 20:24:13 +09:00
EJClaw
3d76cd6c52 fix(docker): run official bot (dave/bot.mjs) in container, not selfbot join.mjs
The `join` entrypoint command now execs `node dave/bot.mjs` and forwards args;
Dockerfile comments/env notes updated from DISCORD_SELFBOT_TOKEN to the official
bot path. Rebuilt watch_sceen_ai:test on .9 and verified in-container:
  * smoke -> 4 passed
  * voice -> eyes-free mock loop runs
  * join  -> LIVE bot join (테스트봇#9029) to guild 사지방 / channel 일반, Ready, clean leave

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-16 18:36:24 +09:00
EJClaw
27e449f9f1 feat(discord): migrate voice join from selfbot to official bot (bot.mjs)
Replace the user-token selfbot voice path with an official Discord bot using
discord.js 14 + @discordjs/voice 0.19. The bot logs in with the stored testbot
token, joins the target voice channel, passes the DAVE/MLS E2EE handshake, and
receives per-user Opus audio via VoiceReceiver (the STT input path). ToS-safe.

Live-verified: bot joined guild "사지방" / channel "일반" and reached Ready.
Selfbot (gate.mjs/join.mjs) kept only for the deferred screenshare-video track,
which official bots cannot receive. Docs updated (README/PLAN); M1 done on bot path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-16 18:32:44 +09:00
EJClaw
5327f8ec7c feat(dave): join with mic on (self_mute=false) so bot can speak and hear
Previously the selfbot joined self_mute=true (listen-only). Un-mute so both
directions are enabled at the Discord voice-state level for live testing.
2026-08-15 21:01:37 +09:00
EJClaw
3cc262ed18 fix(pipeline): cancel sibling loops on failure (TaskGroup, no close-during-use)
Pipeline.run() used asyncio.gather, so if one loop raised, the failing
coroutine propagated while the sibling loops kept running detached; aclose()
in the finally then closed a source/stt out from under a still-live loop.
Switch to asyncio.TaskGroup so a failing loop cancels+awaits the siblings
before teardown. Add a regression test asserting an error in the conversation
loop cancels the perception loop and still closes every source.
2026-08-15 20:57:49 +09:00
EJClaw
cfad568029 fix(dave): hard RUN_MS time-box ceiling armed at startup, not on ready gate
The RUN_MS auto-leave was only armed inside announceReady(), which requires a
fully successful join (DAVE/MLS op29/op30 -> mlsReady). When the E2EE handshake
stalls after op26 key_package, announceReady never fires, so the "time-boxed"
selfbot ran unbounded in a live channel. Arm RUN_MS at process startup instead,
independent of handshake state, and make leaveAndExit idempotent so the ceiling,
ready timer, and signal handlers can't double-fire.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-15 19:28:14 +09:00
EJClaw
12f7399b36 build(docker): GPU-ready test image for .9 (CUDA 12.4 + py3.11 + node22/ffmpeg)
Bundles the M1 milestone: mock voice pipeline (wsai) + dave/ selfbot voice
joiner. Entrypoint dispatches smoke/voice/mock/gpu/join/shell. .env mounted at
runtime (not baked). Built GPU-capable via NVIDIA CDI so later STT/TTS drop in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-15 19:22:49 +09:00
EJClaw
9f3a57d8a0 feat(discord): M1 persistent selfbot voice joiner + record confirmed build decisions
join.mjs joins the target voice channel over the proven DAVE handshake and stays
connected, mapping SPEAKING->ssrc and tallying incoming RTP (foundation for M2
audio decrypt). README records GPU=on, shared OAuth brain, natural-but-<=1s TTS,
Discord-voice STT input, and the M1..M6 milestones.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-11 20:51:24 +09:00
EJClaw
c93691cc26 docs(readme): hold Brain at 350ms, trim STT 250->150 and TTS 250->170 (total ~0.85s)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-11 20:35:30 +09:00
EJClaw
1eb6620eb3 docs(readme): tighten latency budget to <=1s, STT via Discord voice, Korean human-like TTS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-11 17:52:00 +09:00
EJClaw
b0ab23909e docs(readme): refocus on voice loop first, defer screen share
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-11 00:07:50 +09:00
EJClaw
2b6059141e feat(voice): run pipeline eyes-free (STT -> Brain -> TTS), defer screen share
Make source/vision optional so the conversation loop runs with no screen
capture. Add Settings.voice() preset and `python -m wsai --voice` demo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-11 00:05:02 +09:00
EJClaw
b0780988a2 docs(readme): rewrite as implementation blueprint (latency budget, dual eye tracks, milestones)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-09 22:00:44 +09:00
EJClaw
932e1b76b2 docs(readme): fix run commands to use workspace venv (python/pytest not on PATH)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-09 21:53:09 +09:00
EJClaw
d756ea4cf5 docs(readme): rewrite as accurate project summary (selfbot receive + DAVE gate 1)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-09 21:51:42 +09:00
EJClaw
375e1e5539 feat(dave): pass arbiter gate 1 — selfbot joins DAVE/MLS E2EE voice group
Fail-fast checkpoint the arbiter mandated before committing to option A
(protocol-level selfbot stream receive). dave/gate.mjs proves, live against
Discord, that a user token can pass the voice DAVE/MLS handshake:

- voice GW v8 IDENTIFY with max_dave_protocol_version=1 -> NO close 4017
- dave_protocol_version=1 negotiated (E2EE active on the channel)
- @snazzah/davey drives full MLS membership: op25 external_sender -> op26
  key_package -> op27 proposals -> op28 commit_welcome -> op29 announce_commit
  -> MLS session ready=true, stable 5s, voicePrivacyCode derived

Confirms option A is viable: the selfbot can join the E2EE group as a full
member, which is the prerequisite for receiving+decrypting the video RTP.
PLAN.md updated with gate result, exact binary framing, and next A steps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-09 21:33:55 +09:00
claude-owner
07e773a5ac docs(plan): switch decision #1 to selfbot protocol-level stream receive
User rejected browser/screenshot capture: they want to actually receive the
real-time screen-share stream like a real client. Research confirms this needs a
selfbot (user token) via discord.js-selfbot-v13 (werift-rtp) exposing incoming
video RTP (receiverData/hasVideo, VoiceReceiver, StreamConnection); decode via
libsodium decrypt + depayload + ffmpeg. Records ToS/ban risk and that no
turnkey receive->frames library exists. Xvfb browser PoC demoted to fallback.
2026-08-09 20:57:48 +09:00
claude-owner
7d20a40a84 feat(capture): lock decision #1 and prove Xvfb+Chromium frame capture
Decision #1: watch the real Discord screen share by running a real web client
under a virtual display (Xvfb) and capturing rendered frames — not bot/selfbot
protocol receive. PLAN.md records all locked decisions and the step list.

PoC (poc/capture_xvfb.py + test_page.html) launches system Chrome non-headless
under Xvfb via Playwright and captures 6 changing, non-blank frames -> de-risks
the display+capture chain on the .9 host.
2026-08-09 20:52:57 +09:00
claude-owner
4eeddc4b1f feat: scaffold watch-screen AI pipeline (mock-runnable skeleton)
Modular async pipeline: FrameSource->Vision->context and STT/text->Brain->TTS.
All stages are Protocols; mock backends run end-to-end with no deps/keys.
Real backends included: mss screen capture, Claude vision+brain (guarded imports).
2026-08-09 02:16:14 +09:00
EJClaw
0c90856282 chore: initialize paired workspace 2026-08-08 22:08:35 +09:00
40 changed files with 6605 additions and 14 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.venv/
**/__pycache__/
*.pyc
.pytest_cache/
dave/node_modules/
poc/frames/
.git
.env

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
*.egg-info/
poc/frames/
.env
node_modules/

46
Dockerfile Normal file
View 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
View 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만. 큰 이해는 클라우드.

View File

@@ -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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

16
dave/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()

View File

View 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
View 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
View 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
View 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

View 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
View 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
View 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

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

96
wsai/factory.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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)