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>
233 lines
9.0 KiB
Python
233 lines
9.0 KiB
Python
"""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()
|