feat: couple broadcast to voice + voice-controlled broadcast toggle

Completes the STREAM_BROWSER=true behaviour:
- handleJoin auto-starts the broadcast on voice join and wires the session to
  the guild streamer; each turn reports the live state to the brain so search
  routes Chrome (live) vs Gemini (off).
- New setBroadcast tool lets the user toggle the broadcast by voice ("방송
  켜줘/꺼줘") via the LLM (no hardcoded phrases); it refuses when
  STREAM_BROWSER=false. The directive flows brain -> bridge (broadcast_action)
  -> bot streamer.start/stop, guarded by isActive() so it's idempotent.
- Per-turn IPC uses a thread-local (reply/turn_state.py) instead of threading
  params through the whole engine chain: bridge sets broadcasting in, tool
  records the directive out; Tool.execute exposes broadcasting on ToolContext.

Bot typecheck clean; brain covered by tests/test_set_broadcast.py (+ existing
routing tests). Specs + docs updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-11 10:08:30 +09:00
parent 35e754d6ee
commit ca86390407
11 changed files with 316 additions and 14 deletions

View File

@@ -165,8 +165,14 @@ def transcribe(wav_bytes: bytes) -> dict:
return {"text": text, "language": getattr(info, "language", None)}
def think(text: str, language: Optional[str] = None) -> dict:
"""Run the Jarvis reply engine on a piece of text."""
def think(text: str, language: Optional[str] = None, broadcasting: Optional[bool] = None) -> dict:
"""Run the Jarvis reply engine on a piece of text.
``broadcasting`` is the bot's live screen-share state for this turn; it is
stashed in request-scoped state so the search routing can pick Chrome vs
Gemini. If the reply engine calls the setBroadcast tool, the recorded
directive is returned as ``broadcast_action`` for the bot to act on.
"""
if not BRAIN_ENABLED:
return {"reply": text, "error": "brain disabled (JARVIS_BRAIN_ENABLED=0)"}
_ensure_brain()
@@ -174,6 +180,10 @@ def think(text: str, language: Optional[str] = None) -> dict:
return {"reply": "", "error": _brain_error or "brain unavailable"}
try:
from jarvis.reply.engine import run_reply_engine
from jarvis.reply import turn_state
turn_state.reset()
turn_state.set_broadcasting(broadcasting)
# tts=None: we do our own Discord-side synthesis, the engine must not
# try to speak to a local speaker that doesn't exist in this process.
@@ -183,11 +193,20 @@ def think(text: str, language: Optional[str] = None) -> dict:
reply = (reply or "").strip()
if reply:
_dialogue_memory.add_interaction(text, reply)
return {"reply": reply}
return {"reply": reply, "broadcast_action": turn_state.get_broadcast_action()}
except Exception as e: # pragma: no cover
return {"reply": "", "error": f"{type(e).__name__}: {e}"}
def _coerce_bool(value) -> Optional[bool]:
"""Parse a broadcasting flag from JSON (bool) or a query string."""
if value is None:
return None
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "on")
def synthesize(text: str) -> Optional[bytes]:
"""Synthesize text to a 16-bit PCM WAV using Piper. Returns None if TTS off."""
if not TTS_ENABLED or not text.strip():
@@ -234,7 +253,7 @@ def http_text():
text = (data.get("text") or "").strip()
if not text:
return jsonify({"error": "missing 'text'"}), 400
result = think(text, data.get("language"))
result = think(text, data.get("language"), _coerce_bool(data.get("broadcasting")))
audio = synthesize(result.get("reply", ""))
if audio:
result["audio_b64"] = base64.b64encode(audio).decode("ascii")
@@ -263,7 +282,8 @@ def http_converse():
transcript = stt.get("text", "")
if not transcript:
return jsonify({"transcript": "", "reply": "", "audio_b64": None})
result = think(transcript, stt.get("language"))
broadcasting = _coerce_bool(request.args.get("broadcasting"))
result = think(transcript, stt.get("language"), broadcasting)
audio = synthesize(result.get("reply", ""))
return jsonify(
{
@@ -271,6 +291,7 @@ def http_converse():
"language": stt.get("language"),
"reply": result.get("reply", ""),
"error": result.get("error"),
"broadcast_action": result.get("broadcast_action"),
"audio_b64": base64.b64encode(audio).decode("ascii") if audio else None,
}
)