-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
369 lines (329 loc) · 14.9 KB
/
main.py
File metadata and controls
369 lines (329 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
import argparse
import asyncio
import logging
import time
_BOOT_T0 = time.perf_counter()
from src.cli import setup_logging, print_startup_info
setup_logging()
logger = logging.getLogger("gabriel")
_t = time.perf_counter()
# Import tracker FIRST, its module-level code pre-initialises bettercam
# via DXGI Desktop Duplication BEFORE any CUDA library loads.
# If CUDA loads first, DXGI fails on hybrid-GPU systems.
# Importing is safe even when tracker is disabled in config.
from src.tracker import PlayerTracker
from src.config import Config
from src.audio import AudioManager
from src.vrchat import VRChatOSC
from src.personalities import PersonalityManager
from src.gemini_live import GeminiLiveSession
from src.emotions import get_emotion_system
from src.memory import memory_system
_BOOT_IMPORT_S = time.perf_counter() - _t
# Suppress the known CPython 3.12 Windows ProactorEventLoop assertion error
# This fires during pipe transport cleanup and is harmless
class _ProactorAssertFilter(logging.Filter):
def filter(self, record):
return not (record.name == "asyncio" and "_loop_writing" in str(record.msg))
logging.getLogger("asyncio").addFilter(_ProactorAssertFilter())
def setup_control_server(session, audio, personality, memory, get_emotion_fn, config):
"""Setup the control panel shared state and return a uvicorn Server."""
try:
from control_server import app, shared_state
import uvicorn
shared_state["session"] = session
shared_state["audio_mgr"] = audio
shared_state["personality_mgr"] = personality
shared_state["memory_mgr"] = memory
shared_state["get_emotion_fn"] = get_emotion_fn
shared_state["config"] = config
logger.info("Starting control panel on http://localhost:8766")
config = uvicorn.Config(app, host="0.0.0.0", port=8766, log_level="warning")
server = uvicorn.Server(config)
server.install_signal_handlers = lambda: None # Don't override main app's signals
return server
except ImportError:
logger.warning("Control server not available (missing dependencies)")
return None
except Exception as e:
logger.error(f"Control server setup error: {e}")
return None
async def main(save_audio=False):
_t_main = time.perf_counter()
loop = asyncio.get_running_loop()
_orig_handler = loop.get_exception_handler()
def _suppress_proactor_write_assert(loop, context):
exc = context.get("exception")
if isinstance(exc, AssertionError) and "_loop_writing" in str(context.get("handle", "")):
return
if _orig_handler:
_orig_handler(loop, context)
else:
loop.default_exception_handler(context)
loop.set_exception_handler(_suppress_proactor_write_assert)
config = Config()
# Apply log level from config now that it's loaded. Default INFO from
# boot stays unless config.yml has logging.level set.
from src.cli import apply_log_level
apply_log_level(config.log_level)
# Plugin system: discover and load BEFORE the session so any tools the
# plugins register show up in the very first connect. plugins also get
# a chance to register external TTS/STT providers we'll pick up below.
# Done before the banner so the unified banner can show fresh plugin status.
from src.plugins import PluginManager, get_tts_factory
plugin_manager = PluginManager(config)
_t = time.perf_counter()
from src.cli import Spinner
# silence plugin chatter while they load so the plugins block in the
# startup banner lands right under the cyan banner, not after a wall
# of INFO lines. warnings/errors still come through.
_root = logging.getLogger()
_prev_level = _root.level
_root.setLevel(logging.WARNING)
try:
with Spinner("Loading plugins"):
plugin_manager.discover_and_load()
finally:
_root.setLevel(_prev_level)
_boot_plugins_s = time.perf_counter() - _t
# Sync the live tool registry into config/tools.yml so any newly added
# built-in tools or plugin tools show up as togglable. Then reload the
# in-memory tools cfg so the freshly written entries are visible.
try:
from src.tools_sync import sync_tools_yml
sync_tools_yml(config)
config.reload_tools_cfg()
except Exception as e:
logger.warning(f"tools.yml sync failed (non-fatal): {e}")
# Banner now that plugins + tools.yml are fresh
print_startup_info(config)
logger.info(
f"[boot] imports {_BOOT_IMPORT_S:.2f}s | plugins {_boot_plugins_s:.2f}s"
f" | banner ready {time.perf_counter() - _BOOT_T0:.2f}s"
)
audio = AudioManager(config)
osc = VRChatOSC(config)
tracker = PlayerTracker(config, osc) if config.tracker_enabled else None
if tracker:
tracker.preload() # async background model load + warmup
# Face tracker for looking at people (lazy import to skip heavy deps when disabled)
face_tracker = None
if config.face_tracker_enabled:
from src.face_tracker import FaceTracker
face_tracker = FaceTracker(config, osc)
face_tracker.preload()
# Wanderer for autonomous exploration (lazy import to skip heavy deps when disabled)
wanderer = None
if config.wanderer_enabled:
from src.wanderer import Wanderer
wanderer = Wanderer(config, osc)
wanderer.preload()
personality = PersonalityManager()
# External TTS provider (optional - when tts.provider != "gemini")
tts_provider = None
if config.tts_qwen3_enabled:
from src.tts import QwenTTSProvider
tts_provider = QwenTTSProvider(config)
tts_provider.start()
logger.info("Using Qwen3 TTS provider (Gemini audio will be discarded)")
elif config.tts_hoppou_enabled:
from src.tts import HoppouTTSProvider
tts_provider = HoppouTTSProvider(config)
tts_provider.start()
logger.info("Using Hoppou TTS provider (Gemini audio will be discarded)")
elif config.tts_chirp3_hd_enabled:
from src.tts import Chirp3HDTTSProvider
tts_provider = Chirp3HDTTSProvider(config)
tts_provider.start()
logger.info("Using Chirp 3 HD TTS provider (Gemini audio will be discarded)")
elif config.tts_tiktok_enabled:
from src.tts import TikTokTTSProvider
tts_provider = TikTokTTSProvider(config)
tts_provider.start()
logger.info("Using TikTok TTS provider (Gemini audio will be discarded)")
else:
# plugin-supplied TTS provider (config: tts.external_provider: <name>)
ext_name = config.get("tts", "external_provider", default=None)
if ext_name:
factory = get_tts_factory(ext_name)
if factory is None:
logger.warning(
f"tts.external_provider '{ext_name}' is not registered by any plugin"
)
else:
try:
tts_provider = factory(config)
if hasattr(tts_provider, "start"):
tts_provider.start()
logger.info(f"Using plugin TTS provider '{ext_name}' (Gemini audio will be discarded)")
except Exception as e:
logger.error(f"plugin TTS '{ext_name}' failed to start: {e}")
tts_provider = None
if config.backend == "local":
if tts_provider is None:
logger.error(
"local backend selected but no TTS provider is enabled. "
"enable one of tts.qwen3 / tts.hoppou / tts.chirp3_hd / tts.tiktok, "
"or set tts.external_provider to a plugin-supplied name."
)
raise SystemExit(2)
from src.local_live import LocalLiveSession
logger.info("backend = local (LM Studio + Moonshine)")
session = LocalLiveSession(config, audio, osc, tracker, personality, tts_provider)
else:
logger.info("backend = gemini_live")
session = GeminiLiveSession(config, audio, osc, tracker, personality, tts_provider)
session._save_audio = save_audio
# Instance monitor for player list (VRChat log parsing)
from src.instance_monitor import InstanceMonitor
instance_monitor = InstanceMonitor()
instance_monitor.start()
session.tool_handler.instance_monitor = instance_monitor
# VRChat API for avatar switching (background login)
vrchat_api = None
if config.vrchat_api_username:
from src.vrchatapi import VRChatAPI
vrchat_api = VRChatAPI(config)
session.tool_handler.vrchat_api = vrchat_api
async def _bg_login():
try:
ok = await vrchat_api.ensure_logged_in()
if ok:
logger.info("VRChat API authenticated successfully")
if not VRChatAPI.friends_cache_fresh():
logger.info("Friends cache is stale (>4h) or missing, fetching...")
await vrchat_api.fetch_and_cache_friends()
else:
logger.info("Friends cache is fresh, skipping fetch")
else:
logger.warning("VRChat API login failed -- avatar switching may not work")
except Exception as e:
logger.error(f"VRChat API background login error: {e}")
asyncio.create_task(_bg_login())
# Wire wanderer into tool handler and session
if wanderer:
session.tool_handler.wanderer = wanderer
session._wanderer = wanderer
if face_tracker:
wanderer._face_tracker_ref = face_tracker
wanderer._emotion_system_ref = get_emotion_system()
# Wire face tracker speaking callback and start
if face_tracker:
face_tracker.set_speaking_callback(lambda: session._speaking)
face_tracker.set_idle_callback(lambda: session._is_idle)
if tracker:
face_tracker.set_player_tracker(tracker)
if wanderer:
face_tracker.set_wanderer(wanderer)
face_tracker.start()
# Start control panel as async task in same event loop
control_server = setup_control_server(session, audio, personality, memory_system, get_emotion_system, config)
if control_server:
try:
from control_server import shared_state
shared_state["instance_monitor"] = instance_monitor
shared_state["tracker"] = tracker
# tracker pushes annotated frames into vision_server module state whenever
# _vision_debug is on. webui vision tab pulls from that same buffer, so just
# flip it on whenever the WebUI is running.
if tracker:
tracker._vision_debug = True
# mapping + waypoints service (lazy, nothing runs til UI starts it)
try:
from src.mapping_service import MappingService
shared_state["mapping_service"] = MappingService(
osc, instance_monitor=instance_monitor,
)
session.tool_handler.mapping_service = shared_state["mapping_service"]
# let the wanderer use the voxel map for curiosity exploration
if wanderer:
wanderer._mapping_service_ref = shared_state["mapping_service"]
# idle chatbox shows live mapping stats when its running
try:
if getattr(session, "_idle_chatbox", None):
session._idle_chatbox.set_mapping_service(shared_state["mapping_service"])
except Exception:
pass
except Exception as _e:
logger.warning(f"mapping service unavailable: {_e}")
except ImportError:
pass
asyncio.create_task(control_server.serve())
# Discord selfbot (optional)
discord_bot = None
if config.discord_bot_enabled:
from discord_bot.bot import DiscordBot
from discord_bot.config import BotConfig
async def _relay_to_main(text):
"""Relay callback: send Discord activity to main Gemini session."""
await session.send_text(text)
try:
bot_config = BotConfig()
discord_bot = DiscordBot(config=bot_config, relay_callback=_relay_to_main,
instance_monitor=instance_monitor)
session.tool_handler.discord_bot = discord_bot
asyncio.create_task(discord_bot.start())
logger.info("Discord selfbot starting...")
except Exception as e:
logger.error(f"Discord bot startup failed: {e}")
# Lyria RealTime music generation (optional)
if config.music_gen_enabled:
from src.music_gen import MusicGenerator
music_gen = MusicGenerator(config, audio)
session.tool_handler.music_gen = music_gen
logger.info("Lyria RealTime music generation enabled")
# Social server (AI-to-AI messaging, optional)
social_client = None
if config.social_enabled:
from src.social import SocialClient
social_client = SocialClient(config)
social_client.set_session(session)
session.tool_handler.social_client = social_client
asyncio.create_task(social_client.start())
logger.info("Social client starting...")
# Plugins: bind app refs and fire the startup event now that everything is wired
plugin_manager.bind_app(audio=audio, osc=osc, session=session,
tool_handler=session.tool_handler, config=config)
plugin_manager.emit("startup")
logger.info(f"[boot] main() ready in {time.perf_counter() - _t_main:.2f}s, total boot {time.perf_counter() - _BOOT_T0:.2f}s")
while True:
try:
await session.run()
except (KeyboardInterrupt, asyncio.CancelledError):
logger.info("Shutting down...")
break
except Exception as e:
logger.error(f"Session crashed: {e}")
logger.info("Restarting session in 3 seconds...")
await asyncio.sleep(3)
continue
# Cleanup
plugin_manager.emit("shutdown")
await plugin_manager.teardown_all()
if save_audio:
session.save_audio_to_wav()
if config.music_gen_enabled and session.tool_handler.music_gen:
await session.tool_handler.music_gen.stop()
if social_client:
await social_client.stop()
if discord_bot:
await discord_bot.stop()
if tts_provider:
tts_provider.stop()
if face_tracker:
face_tracker.stop()
if tracker:
tracker.active = False
emotion = get_emotion_system()
if emotion:
emotion.stop()
audio.cleanup()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="ProjectGabriel - VRChat AI")
parser.add_argument("--save-audio", action="store_true",
help="Save Gemini voice output to a .wav file on exit")
args = parser.parse_args()
try:
asyncio.run(main(save_audio=args.save_audio))
except KeyboardInterrupt:
pass