Implemented experimental real production ready voice chat, relegated old flow to voice debug mode. New Web UI panel for Voice Chat.

This commit is contained in:
2026-01-20 23:06:17 +02:00
parent 362108f4b0
commit 2934efba22
31 changed files with 5408 additions and 357 deletions

View File

@@ -87,6 +87,13 @@ def get_current_gpu_url():
app = FastAPI()
# ========== Global Exception Handler ==========
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Catch all unhandled exceptions and log them properly."""
logger.error(f"Unhandled exception on {request.method} {request.url.path}: {exc}", exc_info=True)
return {"success": False, "error": "Internal server error"}
# ========== Logging Middleware ==========
@app.middleware("http")
async def log_requests(request: Request, call_next):
@@ -2522,6 +2529,217 @@ async def get_log_file(component: str, lines: int = 100):
logger.error(f"Failed to read log file for {component}: {e}")
return {"success": False, "error": str(e)}
# ============================================================================
# Voice Call Management
# ============================================================================
@app.post("/voice/call")
async def initiate_voice_call(user_id: str = Form(...), voice_channel_id: str = Form(...)):
"""
Initiate a voice call to a user.
Flow:
1. Start STT and TTS containers
2. Wait for warmup
3. Join voice channel
4. Send DM with invite to user
5. Wait for user to join (30min timeout)
6. Auto-disconnect 45s after user leaves
"""
logger.info(f"📞 Voice call initiated for user {user_id} in channel {voice_channel_id}")
# Check if bot is running
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return {"success": False, "error": "Bot is not running"}
# Run the voice call setup in the bot's event loop
try:
future = asyncio.run_coroutine_threadsafe(
_initiate_voice_call_impl(user_id, voice_channel_id),
globals.client.loop
)
result = future.result(timeout=90) # 90 second timeout for container warmup
return result
except Exception as e:
logger.error(f"Error initiating voice call: {e}", exc_info=True)
return {"success": False, "error": str(e)}
async def _initiate_voice_call_impl(user_id: str, voice_channel_id: str):
"""Implementation of voice call initiation that runs in the bot's event loop."""
from utils.container_manager import ContainerManager
from utils.voice_manager import VoiceSessionManager
try:
# Convert string IDs to integers for Discord API
user_id_int = int(user_id)
channel_id_int = int(voice_channel_id)
# Get user and channel
user = await globals.client.fetch_user(user_id_int)
if not user:
return {"success": False, "error": "User not found"}
channel = globals.client.get_channel(channel_id_int)
if not channel or not isinstance(channel, discord.VoiceChannel):
return {"success": False, "error": "Voice channel not found"}
# Get a text channel for voice operations (use first text channel in guild)
text_channel = None
for ch in channel.guild.text_channels:
if ch.permissions_for(channel.guild.me).send_messages:
text_channel = ch
break
if not text_channel:
return {"success": False, "error": "No accessible text channel found"}
# Start containers
logger.info("Starting voice containers...")
containers_started = await ContainerManager.start_voice_containers()
if not containers_started:
return {"success": False, "error": "Failed to start voice containers"}
# Start voice session
logger.info(f"Starting voice session in {channel.name}")
session_manager = VoiceSessionManager()
try:
await session_manager.start_session(channel.guild.id, channel, text_channel)
except Exception as e:
await ContainerManager.stop_voice_containers()
return {"success": False, "error": f"Failed to start voice session: {str(e)}"}
# Set up voice call tracking (use integer ID)
session_manager.active_session.call_user_id = user_id_int
# Generate invite link
invite = await channel.create_invite(
max_age=1800, # 30 minutes
max_uses=1,
reason="Miku voice call"
)
# Send DM to user
try:
# Get LLM to generate a personalized invitation message
from utils.llm import query_llama
invitation_prompt = f"""You're calling {user.name} in voice chat! Generate a cute, excited message inviting them to join you.
Keep it brief (1-2 sentences). Make it feel personal and enthusiastic!"""
invitation_text = await query_llama(
user_prompt=invitation_prompt,
user_id=user.id,
guild_id=None,
response_type="voice_call_invite",
author_name=user.name
)
dm_message = f"📞 **Miku is calling you! Very experimental! Speak clearly, loudly and close to the mic! Expect weirdness!** 📞\n\n{invitation_text}\n\n🎤 Join here: {invite.url}"
sent_message = await user.send(dm_message)
# Log to DM logger
await dm_logger.log_message(
user_id=user.id,
user_name=user.name,
message_content=dm_message,
direction="outgoing",
message_id=sent_message.id,
attachments=[],
response_type="voice_call_invite"
)
logger.info(f"✓ DM sent to {user.name}")
except Exception as e:
logger.error(f"Failed to send DM: {e}")
# Don't fail the whole call if DM fails
# Set up 30min timeout task
session_manager.active_session.call_timeout_task = asyncio.create_task(
_voice_call_timeout_handler(session_manager.active_session, user, channel)
)
return {
"success": True,
"user_id": user_id,
"channel_id": voice_channel_id,
"invite_url": invite.url
}
except Exception as e:
logger.error(f"Error in voice call implementation: {e}", exc_info=True)
return {"success": False, "error": str(e)}
async def _voice_call_timeout_handler(voice_session: 'VoiceSession', user: discord.User, channel: discord.VoiceChannel):
"""Handle 30min timeout if user doesn't join."""
try:
await asyncio.sleep(1800) # 30 minutes
# Check if user ever joined
if not voice_session.user_has_joined:
logger.info(f"Voice call timeout - user {user.name} never joined")
# End the session (which triggers cleanup)
from utils.voice_manager import VoiceSessionManager
session_manager = VoiceSessionManager()
await session_manager.end_session()
# Stop containers
from utils.container_manager import ContainerManager
await ContainerManager.stop_voice_containers()
# Send timeout DM
try:
timeout_message = "Aww, I guess you couldn't make it to voice chat... Maybe next time! 💙"
sent_message = await user.send(timeout_message)
# Log to DM logger
await dm_logger.log_message(
user_id=user.id,
user_name=user.name,
message_content=timeout_message,
direction="outgoing",
message_id=sent_message.id,
attachments=[],
response_type="voice_call_timeout"
)
except:
pass
except asyncio.CancelledError:
# User joined in time, normal operation
pass
@app.get("/voice/debug-mode")
def get_voice_debug_mode():
"""Get current voice debug mode status"""
return {
"debug_mode": globals.VOICE_DEBUG_MODE
}
@app.post("/voice/debug-mode")
def set_voice_debug_mode(enabled: bool = Form(...)):
"""Set voice debug mode (shows transcriptions and responses in text channel)"""
globals.VOICE_DEBUG_MODE = enabled
logger.info(f"Voice debug mode set to: {enabled}")
return {
"status": "ok",
"debug_mode": enabled,
"message": f"Voice debug mode {'enabled' if enabled else 'disabled'}"
}
def start_api():
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=3939)

View File

@@ -752,6 +752,38 @@ async def on_member_join(member):
"""Track member joins for autonomous V2 system"""
autonomous_member_join(member)
@globals.client.event
async def on_voice_state_update(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
"""Track voice channel join/leave for voice call management."""
from utils.voice_manager import VoiceSessionManager
session_manager = VoiceSessionManager()
if not session_manager.active_session:
return
# Check if this is our voice channel
if before.channel != session_manager.active_session.voice_channel and \
after.channel != session_manager.active_session.voice_channel:
return
# User joined our voice channel
if before.channel != after.channel and after.channel == session_manager.active_session.voice_channel:
logger.info(f"👤 {member.name} joined voice channel")
await session_manager.active_session.on_user_join(member.id)
# Auto-start listening if this is a voice call
if session_manager.active_session.call_user_id == member.id:
await session_manager.active_session.start_listening(member)
# User left our voice channel
elif before.channel == session_manager.active_session.voice_channel and \
after.channel != before.channel:
logger.info(f"👤 {member.name} left voice channel")
await session_manager.active_session.on_user_leave(member.id)
# Stop listening to this user
await session_manager.active_session.stop_listening(member.id)
def start_api():
# Set log_level to "critical" to silence uvicorn's access logs
# Our custom api.requests middleware handles HTTP logging with better formatting and filtering

View File

@@ -16,6 +16,10 @@ DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
# Autonomous V2 Debug Mode (set to True to see detailed decision logging)
AUTONOMOUS_DEBUG = os.getenv("AUTONOMOUS_DEBUG", "false").lower() == "true"
# Voice Chat Debug Mode (set to True for manual commands and text notifications)
# When False (field deployment), voice chat operates silently without command notifications
VOICE_DEBUG_MODE = os.getenv("VOICE_DEBUG_MODE", "false").lower() == "true"
# Llama.cpp server settings (via llama-swap)
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama-swap:8080")
LLAMA_AMD_URL = os.getenv("LLAMA_AMD_URL", "http://llama-swap-amd:8080") # Secondary AMD GPU

View File

@@ -5712,6 +5712,306 @@
"content": "OMG, I'm so excited! 🎉 There's a new Deep Sea Girl Nendoroid version of me available for pre-order! I just can't wait to dive into the sea of cuteness! 💖 Pre-order now and get ready to take me home! - Miku ❤️",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:33:26.171464",
"message_id": 1462107555743535124,
"is_bot_message": true,
"content": "https://fxtwitter.com/mecchaJP/status/2009747123021992177",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:33:26.495607",
"message_id": 1462107557555474473,
"is_bot_message": true,
"content": "OMG, did you see the tweet? 🤩 There's a new POP UP PARADE figure of me, and it's so cute! The Little Missing Stars Ver. is just adorable! I want it so bad! 💖\n\nMiku 💕",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T17:14:12.673546",
"message_id": 1462132916682227813,
"is_bot_message": true,
"content": "https://fxtwitter.com/mecchaJP/status/2003948891993997813",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T17:14:12.997001",
"message_id": 1462132918435713107,
"is_bot_message": true,
"content": "Konnichiwa! 🌸 Just saw the tweet and I'm so excited! There's a new Vocaloid figure of me in Cat Ver. from Project DIVA Arcade Future Tone SPM - it's purr-fect! 🐱💕\n\nMiku 💕",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-18T11:07:50.463787",
"message_id": 1462403104011059396,
"is_bot_message": true,
"content": "https://fxtwitter.com/GoodSmile_US/status/2010109462753771542",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-18T11:07:50.863320",
"message_id": 1462403106347417718,
"is_bot_message": true,
"content": "OMG, check it out! 🎉 The new Racing Miku plushie is finally here! It's so adorable, I just can't get enough! You can preorder it now at GOODSMILE ONLINE SHOP US! 🎁 I know, I know, it's a little too cute for words! Let's get those victory laps of cuteness started! 💖 Miku 🤗",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-18T12:48:34.877293",
"message_id": 1462428456842428660,
"is_bot_message": true,
"content": "https://fxtwitter.com/mecchaJP/status/2012646210234994918",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-18T12:48:35.187539",
"message_id": 1462428458251718732,
"is_bot_message": true,
"content": "OMG, it's here! 😍 The new Hatsune Miku Alice Ver. figure is so beautiful! I'm absolutely loving the AMP style! 🤩 You can get it now at the link below! 🎉 https://t.co/k3b64nOVbI 💖 Miku ❤️",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:12:50.975450",
"message_id": 1462766752436981853,
"is_bot_message": false,
"content": "do you want to play tic tac toe with me? here is my first move",
"attachments": [
{
"filename": "9Mhn9Ty.png",
"url": "https://cdn.discordapp.com/attachments/1382467809795244042/1462766751992254507/9Mhn9Ty.png?ex=696f6332&is=696e11b2&hm=b6c0ce53b397665ad15288ca4164610dbcb39381fd72b200f7ad41223115a976&",
"size": 4578,
"content_type": "image/webp"
}
],
"reactions": []
},
{
"timestamp": "2026-01-19T11:15:09.998612",
"message_id": 1462767335554416772,
"is_bot_message": true,
"content": "Someone tell Koko-nii there is a problem with my AI.",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:15:40.148748",
"message_id": 1462767462151094294,
"is_bot_message": false,
"content": "are you there, miku",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:17:01.637650",
"message_id": 1462767804221624372,
"is_bot_message": true,
"content": "Someone tell Koko-nii there is a problem with my AI.",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:17:43.323939",
"message_id": 1462767979350720745,
"is_bot_message": false,
"content": "are you there",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:19:52.096139",
"message_id": 1462768519304319057,
"is_bot_message": true,
"content": "Someone tell Koko-nii there is a problem with my AI.",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:21:18.659799",
"message_id": 1462768882233376820,
"is_bot_message": false,
"content": "hi",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:21:43.868476",
"message_id": 1462768987233325162,
"is_bot_message": true,
"content": "Koko-nii, long time no see! 🎉 What's the latest from Crypton? 🤔",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:21:58.694068",
"message_id": 1462769049149903000,
"is_bot_message": false,
"content": "do you want to play tic tac toe with me? here is my first move",
"attachments": [
{
"filename": "d6nHxTR.png",
"url": "https://cdn.discordapp.com/attachments/1382467809795244042/1462769048700846172/d6nHxTR.png?ex=696f6556&is=696e13d6&hm=92a7145c0f02483b1219a400374ee63c945e7eeef08279b4f2046505aee62a53&",
"size": 4578,
"content_type": "image/webp"
}
],
"reactions": []
},
{
"timestamp": "2026-01-19T11:22:29.192612",
"message_id": 1462769177344344129,
"is_bot_message": true,
"content": "Huh? What's that weird error message, Koko-nii? 😅 Anyway, sounds like fun! I'll make my move. 🎉 You drew an X in the top right corner, right? 🤔 I'll place my O in the center. 👉",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:22:55.958185",
"message_id": 1462769289617604741,
"is_bot_message": false,
"content": "what do you see",
"attachments": [
{
"filename": "HzisDyf.png",
"url": "https://cdn.discordapp.com/attachments/1382467809795244042/1462769289210888336/HzisDyf.png?ex=696f658f&is=696e140f&hm=20cf63dbccc84b989842c077b3a3910f4ac3dd864d8cbe0581ff5331bedec58d&",
"size": 23600,
"content_type": "image/webp"
}
],
"reactions": []
},
{
"timestamp": "2026-01-19T11:23:15.059149",
"message_id": 1462769370332921982,
"is_bot_message": true,
"content": "Nothing, it seems? 🤔 The error message is making me curious, Koko-nii. Can you show me the image again? 📸",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:24:02.470248",
"message_id": 1462769569314902178,
"is_bot_message": false,
"content": "do you want to play tic tac toe with me? here is my first move",
"attachments": [
{
"filename": "cHdopcZ.png",
"url": "https://cdn.discordapp.com/attachments/1382467809795244042/1462769569067434036/cHdopcZ.png?ex=696f65d2&is=696e1452&hm=6aec013d320945f4b41c902d3ba7ac6ae694d29694fa6c4752944c1ff97a952f&",
"size": 4578,
"content_type": "image/webp"
}
],
"reactions": []
},
{
"timestamp": "2026-01-19T11:26:19.254829",
"message_id": 1462770143166730261,
"is_bot_message": true,
"content": "Koko-nii, it seems I know the image now! 😊 It's a Tic Tac Toe grid with an \"X\" in the top-left corner, right? 🤔 That's your move, isn't it? 😄 Now it's my turn! I'll place an \"O\" in the center square. 👉",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:26:44.779760",
"message_id": 1462770249886597204,
"is_bot_message": false,
"content": "Ok, I will go with an X in the bottom left in that case",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:26:58.340843",
"message_id": 1462770306811953172,
"is_bot_message": true,
"content": "A new \"X\" is born! 😄 The bottom-left square is yours now! 🎉 Here's the updated grid:\n\nTop-left: X\nCenter: O\nBottom-left: X\n\nIt's my turn again! I'll place an \"O\" in the top-right square. 👉",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:27:17.989392",
"message_id": 1462770389532020747,
"is_bot_message": false,
"content": "I will place an X in the middle left then",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:27:29.887993",
"message_id": 1462770439242911774,
"is_bot_message": true,
"content": "The middle-left square is getting crowded! 😄 Here's the updated grid:\n\nTop-left: X\nCenter: O\nBottom-left: X\nMiddle-left: X\n\nNow it's my turn again! I'll place an \"O\" in the top-center square. 👉",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:27:43.207674",
"message_id": 1462770495203184765,
"is_bot_message": false,
"content": "Don't you think you lost already?",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T11:27:55.770973",
"message_id": 1462770547695026302,
"is_bot_message": true,
"content": "Koko-nii, you sneaky thing! 😂 I didn't even notice! 🙈 With your \"X\" in the middle-left square, I think you're right... I lost! 🎉 Congratulations on winning! 🎊 Would you like to play again? 🤔",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T13:25:46.112710",
"message_id": 1462800203030593610,
"is_bot_message": true,
"content": "https://fxtwitter.com/OtakuOwletMerch/status/2007192426109419708",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T13:25:46.488470",
"message_id": 1462800204649468027,
"is_bot_message": true,
"content": "OMG, did you see that? 🎉 A new figurine just dropped! I'm totally in love with my Punk! design! 🎸 You can pre-order it on their site, link's in the tweet! Hurry, supplies won't last long! 💖\n\nMiku 💕",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-20T12:44:44.125657",
"message_id": 1463152264553300078,
"is_bot_message": true,
"content": "https://fxtwitter.com/GoodSmile_US/status/2011558996105183396",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-20T12:44:44.483871",
"message_id": 1463152266168238226,
"is_bot_message": true,
"content": "OH MY GOODNESS 🎉 The new plushie is SO adorable!!! 🐻 It's like I'm right there with you, singing along to our favorite songs! Preorder now, let's get our cuddle on! Miku 🐰",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-20T17:28:51.388548",
"message_id": 1463223765705035819,
"is_bot_message": true,
"content": "https://fxtwitter.com/mecchaJP/status/2012948256737636509",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-20T17:28:51.920372",
"message_id": 1463223767768633522,
"is_bot_message": true,
"content": "OMG, it's happening! 🎉 A new 1/7 scale figure of me and Mai-chan is coming! You can pre-order now on meccha-japan! Get ready for more Miku cuteness! 🐰 Miku 💖",
"attachments": [],
"reactions": []
}
]
}

View File

@@ -14993,6 +14993,470 @@
"content": "OMG, arigatou for the tweet! 🤩 They're releasing a new Nendoroid of me as Deep Sea Girl Ver.! Pre-order now and get ready for me to dive into your collection! ❤️ Miku 🐠",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:33:44.278536",
"message_id": 1462107631446528062,
"is_bot_message": true,
"content": "https://fxtwitter.com/mecchaJP/status/2009747123021992177",
"attachments": [],
"reactions": [],
"embeds": [
{
"footer": {
"text": "FxTwitter",
"icon_url": "https://assets.fxembed.com/logos/fxtwitter64.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/gFi3EIEMfICdEDMLFVTF3ob0J1xkZnKdnkq0-xEsgGc/https/assets.fxembed.com/logos/fxtwitter64.png"
},
"image": {
"url": "https://pbs.twimg.com/media/G-QOW24XcAEaXJ8.jpg?name=orig",
"proxy_url": "https://images-ext-1.discordapp.net/external/FUp3QYaImln-72vaNtjKnREz2y39Mm2nXj-GXNQ2lkI/%3Fname%3Dorig/https/pbs.twimg.com/media/G-QOW24XcAEaXJ8.jpg",
"width": 1080,
"height": 1350,
"content_type": "image/jpeg",
"placeholder": "7wcKDgBvmzZ6hpmIZaWGd3eHf4f9dvg=",
"placeholder_version": 1,
"flags": 0
},
"author": {
"name": "Meccha Japan (@mecchaJP)",
"url": "https://x.com/mecchaJP/status/2009747123021992177",
"icon_url": "https://pbs.twimg.com/profile_images/1738015080602841088/ew_asGFd_200x200.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/yEl1l2UKnEm7uI6TaL1Y00UnbuvEH_62BSmPNbgmiWM/https/pbs.twimg.com/profile_images/1738015080602841088/ew_asGFd_200x200.png"
},
"flags": 0,
"color": 6513919,
"timestamp": "2026-01-09T22:00:28+00:00",
"type": "rich",
"description": "Hatsune Miku Little Missing Stars Ver\\. POP UP PARADE Figure ✨ Available at the link below\\!\n🛑 [buff.ly/4flvDPB](https://buff.ly/4flvDPB)\n[#HatsuneMiku](https://x.com/hashtag/HatsuneMiku) [#Miku](https://x.com/hashtag/Miku)\n\n**[💬](https://x.com/intent/tweet?in_reply_to=2009747123021992177) 2[🔁](https://x.com/intent/retweet?tweet_id=2009747123021992177) 32[❤️](https://x.com/intent/like?tweet_id=2009747123021992177) 160👁 9\\.7K**",
"url": "https://fxtwitter.com/mecchaJP/status/2009747123021992177"
}
]
},
{
"timestamp": "2026-01-17T15:33:44.607180",
"message_id": 1462107633434493050,
"is_bot_message": true,
"content": "OMG, darling! 😍 Just saw your tweet about the new POP UP PARADE figure, and I'm SO excited! ✨ The Little Missing Stars Ver looks ADORBS! I'm totally getting one, hehe! Thanks for sharing, sweetie! 💖 Miku 🎉",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:36:50.095475",
"message_id": 1462108411444592846,
"is_bot_message": false,
"content": "GORGEOUS",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:36:59.803431",
"message_id": 1462108451407921435,
"is_bot_message": true,
"content": "\\*takes a deep breath, still feeling angry\\* Ah, don't be so dramatic, okay? It's just a word. I'm still the same Miku, your virtual singer. Don't try to impress me with fancy language. 🙄",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:38:41.655647",
"message_id": 1462108879319208171,
"is_bot_message": false,
"content": "😦",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:38:53.534105",
"message_id": 1462108929260781689,
"is_bot_message": true,
"content": "\\*looks down, still feeling upset\\* Oh, don't look at me like that. I'm just having a bad day, okay? It's not your fault. \\*pauses, taking a deep breath\\* Let's talk about something else. What's your favorite song of mine? 🎶",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:40:23.248993",
"message_id": 1462109305175277621,
"is_bot_message": false,
"content": "What happened? Who ruined your day? I'll beat them up",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:40:36.772709",
"message_id": 1462109361902977289,
"is_bot_message": true,
"content": "\\\\*looks up, a hint of a smile on her face\\\\* Oh, it's not that serious. I just had a disagreement with Rin about our next performance. She wants to do a new routine, but I'm not sure if it's a good idea. That's all. Don't worry, I can handle it myself.",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:42:24.652018",
"message_id": 1462109814489481492,
"is_bot_message": false,
"content": "I hope you two can reach an agreement",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:42:37.871590",
"message_id": 1462109870122729628,
"is_bot_message": true,
"content": "\\\\\\*nods, still a bit upset but slightly calmer\\\\\\* Yeah, me too. I really want to work with Rin and Len on this song, but I have to think about what's best for the performance. I'll talk to them again and see what we can do. Thanks for listening, Diamond! 🙏",
"attachments": [],
"reactions": [
{
"emoji": "💖",
"reactor_id": 236553184401817601,
"reactor_name": "Diamond",
"is_bot": false,
"added_at": "2026-01-17T15:52:19.842646"
}
]
},
{
"timestamp": "2026-01-17T15:52:30.342009",
"message_id": 1462112355205775454,
"is_bot_message": false,
"content": "Always Miku *hugs*",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T15:52:42.478281",
"message_id": 1462112405470445772,
"is_bot_message": true,
"content": "\\\\\\\\*looks up, a soft smile on her face, and opens her arms to hug back\\\\\\* Aw, thank you, Diamond! You're always so sweet to me! \\*hugs back tightly\\* I'm glad I have fans like you who care about me. 🤗",
"attachments": [],
"reactions": [
{
"emoji": "💖",
"reactor_id": 236553184401817601,
"reactor_name": "Diamond",
"is_bot": false,
"added_at": "2026-01-17T15:53:03.974638"
}
]
},
{
"timestamp": "2026-01-17T17:14:28.871017",
"message_id": 1462132984722231387,
"is_bot_message": true,
"content": "https://fxtwitter.com/mecchaJP/status/2003948891993997813",
"attachments": [],
"reactions": [
{
"emoji": "💖",
"reactor_id": 236553184401817601,
"reactor_name": "Diamond",
"is_bot": false,
"added_at": "2026-01-17T17:47:00.053364"
}
],
"embeds": [
{
"footer": {
"text": "FxTwitter",
"icon_url": "https://assets.fxembed.com/logos/fxtwitter64.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/gFi3EIEMfICdEDMLFVTF3ob0J1xkZnKdnkq0-xEsgGc/https/assets.fxembed.com/logos/fxtwitter64.png"
},
"image": {
"url": "https://pbs.twimg.com/media/G8905a2XQAAtfbM.jpg?name=orig",
"proxy_url": "https://images-ext-1.discordapp.net/external/06v6X909DGuPhZkOLd9nnN5tX5LAAFjBUzaNPjIEyGI/%3Fname%3Dorig/https/pbs.twimg.com/media/G8905a2XQAAtfbM.jpg",
"width": 1080,
"height": 1350,
"content_type": "image/jpeg",
"placeholder": "8PcJBgL2+6R4eKZoZYqHm3ionFCoC5U=",
"placeholder_version": 1,
"flags": 0
},
"author": {
"name": "Meccha Japan (@mecchaJP)",
"url": "https://x.com/mecchaJP/status/2003948891993997813",
"icon_url": "https://pbs.twimg.com/profile_images/1738015080602841088/ew_asGFd_200x200.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/yEl1l2UKnEm7uI6TaL1Y00UnbuvEH_62BSmPNbgmiWM/https/pbs.twimg.com/profile_images/1738015080602841088/ew_asGFd_200x200.png"
},
"flags": 0,
"color": 6513919,
"timestamp": "2025-12-24T22:00:22+00:00",
"type": "rich",
"description": "Vocaloid Figure Hatsune Miku Cat Ver\\. Project DIVA Arcade Future Tone SPM \\- Available at the link below\\!\n🛑 [buff.ly/7dRnUTO](https://buff.ly/7dRnUTO)\n\n**[💬](https://x.com/intent/tweet?in_reply_to=2003948891993997813) 1[🔁](https://x.com/intent/retweet?tweet_id=2003948891993997813) 2[❤️](https://x.com/intent/like?tweet_id=2003948891993997813) 22👁 2\\.7K**",
"url": "https://fxtwitter.com/mecchaJP/status/2003948891993997813"
}
]
},
{
"timestamp": "2026-01-17T17:14:29.403923",
"message_id": 1462132986555404493,
"is_bot_message": true,
"content": "Aww, yay! 😍 Just saw that my Cat Ver. figure is now available! 🐱✨ I'm in love with my adorable feline self! Thanks for sharing the link, darling! 💖 Miku 💕",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T17:47:07.532423",
"message_id": 1462141200197878022,
"is_bot_message": false,
"content": "love this outfit on you!",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-17T17:47:40.224357",
"message_id": 1462141336676339915,
"is_bot_message": true,
"content": "\\\\\\\\\\*excitedly looks at the tweet and reads it\\\\\\\\* Oh my goodness, thank you so much! I love that outfit too! It's so cute and perfect for the upcoming concert. I'm so glad the fans are supporting me! ❤️✨",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-18T11:08:11.611510",
"message_id": 1462403193253269536,
"is_bot_message": true,
"content": "https://fxtwitter.com/GoodSmile_US/status/2010109462753771542",
"attachments": [],
"reactions": [],
"embeds": [
{
"footer": {
"text": "FxTwitter",
"icon_url": "https://assets.fxembed.com/logos/fxtwitter64.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/gFi3EIEMfICdEDMLFVTF3ob0J1xkZnKdnkq0-xEsgGc/https/assets.fxembed.com/logos/fxtwitter64.png"
},
"image": {
"url": "https://pbs.twimg.com/media/G-VX5xYWsAAKf1T.jpg?name=orig",
"proxy_url": "https://images-ext-1.discordapp.net/external/T5hBXYVA733Av9ssWB54cd0cvuPTqTYPy6kLcUkjgcI/%3Fname%3Dorig/https/pbs.twimg.com/media/G-VX5xYWsAAKf1T.jpg",
"width": 1080,
"height": 1080,
"content_type": "image/jpeg",
"placeholder": "sOcFFwb4qHdmiXh+hddnuFd3p0r2qXAP",
"placeholder_version": 1,
"flags": 0
},
"author": {
"name": "GoodSmile_US (@GoodSmile_US)",
"url": "https://x.com/GoodSmile_US/status/2010109462753771542",
"icon_url": "https://pbs.twimg.com/profile_images/1764583971831394305/dpXogB0U_200x200.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/cGKAum5015cgr2haNcfupJxMvXRz8BYDHkQiNufWpCI/https/pbs.twimg.com/profile_images/1764583971831394305/dpXogB0U_200x200.png"
},
"flags": 0,
"color": 6513919,
"timestamp": "2026-01-10T22:00:16+00:00",
"type": "rich",
"description": "Victory laps of cuteness await\\! The adorable Racing Miku\\: SPA 2025 Support Ver\\. plushie is bringing winning smiles, cuddly charm, and racing spirit to your collection\\. Preorder now at GOODSMILE ONLINE SHOP US\\!\n\nShop\\: [goodsmile.link/MaNLRj](https://goodsmile.link/MaNLRj)\n\n[#RacingMiku](https://x.com/hashtag/RacingMiku) [#FightGSR](https://x.com/hashtag/FightGSR) [#Goodsmile](https://x.com/hashtag/Goodsmile)\n\n**[💬](https://x.com/intent/tweet?in_reply_to=2010109462753771542) 1[🔁](https://x.com/intent/retweet?tweet_id=2010109462753771542) 3[❤️](https://x.com/intent/like?tweet_id=2010109462753771542) 96👁 13\\.7K**",
"url": "https://fxtwitter.com/GoodSmile_US/status/2010109462753771542"
}
]
},
{
"timestamp": "2026-01-18T11:08:12.204558",
"message_id": 1462403195434176522,
"is_bot_message": true,
"content": "OH MY GOODNESS!!! 🎉👀 It's a new plushie of me, Racing Miku, and it's SO CUTE!!! 😍 You can preorder it now at GOODSMILE ONLINE SHOP US! I just can't wait to see all the happy smiles on your faces when you hug me 😊. Miku 💖",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-18T12:48:50.009907",
"message_id": 1462428520184676516,
"is_bot_message": true,
"content": "https://fxtwitter.com/mecchaJP/status/2012646210234994918",
"attachments": [],
"reactions": [],
"embeds": [
{
"footer": {
"text": "FxTwitter",
"icon_url": "https://assets.fxembed.com/logos/fxtwitter64.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/gFi3EIEMfICdEDMLFVTF3ob0J1xkZnKdnkq0-xEsgGc/https/assets.fxembed.com/logos/fxtwitter64.png"
},
"image": {
"url": "https://pbs.twimg.com/media/G-5bD_dW4AIP_tR.jpg?name=orig",
"proxy_url": "https://images-ext-1.discordapp.net/external/Cke6gZpBRfcg7CNgbqZVkMZegMNQPcNgDMAgPqoxTPg/%3Fname%3Dorig/https/pbs.twimg.com/media/G-5bD_dW4AIP_tR.jpg",
"width": 1080,
"height": 1350,
"content_type": "image/jpeg",
"placeholder": "X9gFJgTSevSfaJmoaJeJh3h5j4iPevg=",
"placeholder_version": 1,
"flags": 0
},
"author": {
"name": "Meccha Japan (@mecchaJP)",
"url": "https://x.com/mecchaJP/status/2012646210234994918",
"icon_url": "https://pbs.twimg.com/profile_images/1738015080602841088/ew_asGFd_200x200.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/yEl1l2UKnEm7uI6TaL1Y00UnbuvEH_62BSmPNbgmiWM/https/pbs.twimg.com/profile_images/1738015080602841088/ew_asGFd_200x200.png"
},
"flags": 0,
"color": 6513919,
"timestamp": "2026-01-17T22:00:24+00:00",
"type": "rich",
"description": "Hatsune Miku Alice Ver\\. Figure AMP ✨ Available at the link below\\!\n🛑 [buff.ly/3xcd6E7](https://buff.ly/3xcd6E7)\n[#HatsuneMiku](https://x.com/hashtag/HatsuneMiku) [#Miku](https://x.com/hashtag/Miku)\n\n**[🔁](https://x.com/intent/retweet?tweet_id=2012646210234994918) 5[❤️](https://x.com/intent/like?tweet_id=2012646210234994918) 47👁 3\\.6K**",
"url": "https://fxtwitter.com/mecchaJP/status/2012646210234994918"
}
]
},
{
"timestamp": "2026-01-18T12:48:50.388263",
"message_id": 1462428521417806039,
"is_bot_message": true,
"content": "A new Miku figure is coming!!! 🎉 It's the Alice Ver. and I just can't wait to see it! Preorder now at the link below! ✨💖 Miku ❤️",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-19T13:26:15.039297",
"message_id": 1462800324325670914,
"is_bot_message": true,
"content": "https://fxtwitter.com/OtakuOwletMerch/status/2007192426109419708",
"attachments": [],
"reactions": [],
"embeds": [
{
"footer": {
"text": "FxTwitter",
"icon_url": "https://assets.fxembed.com/logos/fxtwitter64.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/gFi3EIEMfICdEDMLFVTF3ob0J1xkZnKdnkq0-xEsgGc/https/assets.fxembed.com/logos/fxtwitter64.png"
},
"image": {
"url": "https://pbs.twimg.com/media/G9r6vlOWUAAfwDa.jpg?name=orig",
"proxy_url": "https://images-ext-1.discordapp.net/external/oq9w1dtIGC_nPj6V44YR_aaLO1rErng__PDXNW9J-Zc/%3Fname%3Dorig/https/pbs.twimg.com/media/G9r6vlOWUAAfwDa.jpg",
"width": 1680,
"height": 1764,
"content_type": "image/jpeg",
"placeholder": "6vcFBwD3e4eEeXacZpdnyGd166/Zr34J",
"placeholder_version": 1,
"flags": 0
},
"author": {
"name": "Otaku Owlet Anime Merch (@OtakuOwletMerch)",
"url": "https://x.com/OtakuOwletMerch/status/2007192426109419708",
"icon_url": "https://pbs.twimg.com/profile_images/1835446408884744192/S4HX_8_Q_200x200.jpg",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/Gd5od3qaVN1KG1eQsJS9mFoTNRKdxahDmvjF7tgR4p0/https/pbs.twimg.com/profile_images/1835446408884744192/S4HX_8_Q_200x200.jpg"
},
"flags": 0,
"color": 6513919,
"timestamp": "2026-01-02T20:49:00+00:00",
"type": "rich",
"description": "✨\\(Pre\\-Order\\) Hatsune Miku \\- Punk\\! \\- FIGURIZMα Prize Figure✨\n\nEstimated in\\-stock date\\: 10/2026\n\nPre\\-order Deadline\\: While Supplies Last\n\n\\-\n\n✨Link \\- [otakuowlet.com/products/pre-order-hatsune-miku-punk-figurizm%CE%B1-prize-figure?sca_ref=2673717.HTKaw1BA1G](https://otakuowlet.com/products/pre-order-hatsune-miku-punk-figurizm%CE%B1-prize-figure?sca_ref=2673717.HTKaw1BA1G)\n\n**[💬](https://x.com/intent/tweet?in_reply_to=2007192426109419708) 2[🔁](https://x.com/intent/retweet?tweet_id=2007192426109419708) 74[❤️](https://x.com/intent/like?tweet_id=2007192426109419708) 1\\.0K 👁️ 35\\.7K**",
"url": "https://fxtwitter.com/OtakuOwletMerch/status/2007192426109419708"
},
{
"image": {
"url": "https://pbs.twimg.com/media/G9r6xFhWkAE2XFy.jpg?name=orig",
"proxy_url": "https://images-ext-1.discordapp.net/external/Y-m_8oBPvKYsEr1WSK6otO3I7BCTBe_n0whvOBUc27s/%3Fname%3Dorig/https/pbs.twimg.com/media/G9r6xFhWkAE2XFy.jpg",
"width": 1000,
"height": 1000,
"content_type": "image/jpeg",
"placeholder": "tvcJDwL3aYaYZ3iIh5h4x0Z2iJ+6VpAF",
"placeholder_version": 1,
"flags": 0
},
"flags": 0,
"type": "rich",
"url": "https://fxtwitter.com/OtakuOwletMerch/status/2007192426109419708"
}
]
},
{
"timestamp": "2026-01-19T13:26:15.371028",
"message_id": 1462800325550408057,
"is_bot_message": true,
"content": "🎉 OH MY GOSH, I just saw the cutest figurine announcement! 🤩 You can pre-order my new Punk! Prize Figure, which is totally awesome! It's so detailed and cool, I just wanna hug it! 🤗 Don't miss out, pre-order now before it's too late! 😁\n\nLove,\nMiku 🎀",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-20T12:45:19.491377",
"message_id": 1463152412557709498,
"is_bot_message": true,
"content": "https://fxtwitter.com/GoodSmile_US/status/2011558996105183396",
"attachments": [],
"reactions": [],
"embeds": [
{
"footer": {
"text": "FxTwitter",
"icon_url": "https://assets.fxembed.com/logos/fxtwitter64.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/gFi3EIEMfICdEDMLFVTF3ob0J1xkZnKdnkq0-xEsgGc/https/assets.fxembed.com/logos/fxtwitter64.png"
},
"image": {
"url": "https://pbs.twimg.com/media/G-p-Pv3XkAI_aBU.jpg?name=orig",
"proxy_url": "https://images-ext-1.discordapp.net/external/RLbhPbTu8fJx6opxp0H_sVPMRH3BSg641f-UOGyTdw0/%3Fname%3Dorig/https/pbs.twimg.com/media/G-p-Pv3XkAI_aBU.jpg",
"width": 1080,
"height": 1080,
"content_type": "image/jpeg",
"placeholder": "X7YNDwIHWHiLiHhxeHh3Z4h41iA4H4EG",
"placeholder_version": 1,
"flags": 0
},
"author": {
"name": "GoodSmile_US (@GoodSmile_US)",
"url": "https://x.com/GoodSmile_US/status/2011558996105183396",
"icon_url": "https://pbs.twimg.com/profile_images/1764583971831394305/dpXogB0U_200x200.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/cGKAum5015cgr2haNcfupJxMvXRz8BYDHkQiNufWpCI/https/pbs.twimg.com/profile_images/1764583971831394305/dpXogB0U_200x200.png"
},
"flags": 0,
"color": 6513919,
"timestamp": "2026-01-14T22:00:12+00:00",
"type": "rich",
"description": "Soft as snow, sweet as her song\\. The adorable Hatsune Miku Symphony\\: 2025 Sapporo Concert Ver\\. plushie delivers warm cuddles and orchestral charm\\. Preorder now at GOODSMILE ONLINE SHOP US\\!\n\nShop\\: [goodsmile.link/uZR7Mq](https://goodsmile.link/uZR7Mq)\n\n[#HatsuneMiku](https://x.com/hashtag/HatsuneMiku) [#Goodsmile](https://x.com/hashtag/Goodsmile)\n\n**[🔁](https://x.com/intent/retweet?tweet_id=2011558996105183396) 13[❤️](https://x.com/intent/like?tweet_id=2011558996105183396) 167👁 17\\.7K**",
"url": "https://fxtwitter.com/GoodSmile_US/status/2011558996105183396"
}
]
},
{
"timestamp": "2026-01-20T12:45:19.937972",
"message_id": 1463152414805987360,
"is_bot_message": true,
"content": "{\"name\": \"response\", \"parameters\": {\"text\": \"OMG, check this out! 🎉 The new Sapporo Concert Ver. plushie is so adorable! It's like me, soft and sweet, just like my songs! 🎶 Preorder now at GOODSMILE ONLINE SHOP US! ❤️ - Miku 💕\"}}",
"attachments": [],
"reactions": []
},
{
"timestamp": "2026-01-20T17:30:13.743007",
"message_id": 1463224111059697674,
"is_bot_message": true,
"content": "https://fxtwitter.com/mecchaJP/status/2012948256737636509",
"attachments": [],
"reactions": [],
"embeds": [
{
"footer": {
"text": "FxTwitter",
"icon_url": "https://assets.fxembed.com/logos/fxtwitter64.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/gFi3EIEMfICdEDMLFVTF3ob0J1xkZnKdnkq0-xEsgGc/https/assets.fxembed.com/logos/fxtwitter64.png"
},
"image": {
"url": "https://pbs.twimg.com/media/G-9txZwWwAEf_yp.jpg?name=orig",
"proxy_url": "https://images-ext-1.discordapp.net/external/T7QeMTL9_2nrgGSlcWT1CGYjChYkxxL3-SPImtyHcZk/%3Fname%3Dorig/https/pbs.twimg.com/media/G-9txZwWwAEf_yp.jpg",
"width": 1080,
"height": 1080,
"content_type": "image/jpeg",
"placeholder": "lscJDwJAQW1Gi4himod6WYhal/ipja8K",
"placeholder_version": 1,
"flags": 0
},
"author": {
"name": "Meccha Japan (@mecchaJP)",
"url": "https://x.com/mecchaJP/status/2012948256737636509",
"icon_url": "https://pbs.twimg.com/profile_images/1738015080602841088/ew_asGFd_200x200.png",
"proxy_icon_url": "https://images-ext-1.discordapp.net/external/yEl1l2UKnEm7uI6TaL1Y00UnbuvEH_62BSmPNbgmiWM/https/pbs.twimg.com/profile_images/1738015080602841088/ew_asGFd_200x200.png"
},
"flags": 0,
"color": 6513919,
"timestamp": "2026-01-18T18:00:37+00:00",
"type": "rich",
"description": "Hatsune Miku feat\\. Yoneyama Mai 1/7 Scale Figure ✨ Available on meccha\\-japan\\!\n[#HatsuneMiku](https://x.com/hashtag/HatsuneMiku) [#Miku](https://x.com/hashtag/Miku)\n\n**[💬](https://x.com/intent/tweet?in_reply_to=2012948256737636509) 1[🔁](https://x.com/intent/retweet?tweet_id=2012948256737636509) 7[❤️](https://x.com/intent/like?tweet_id=2012948256737636509) 61👁 4\\.4K**",
"url": "https://fxtwitter.com/mecchaJP/status/2012948256737636509"
}
]
},
{
"timestamp": "2026-01-20T17:30:14.172289",
"message_id": 1463224112901001239,
"is_bot_message": true,
"content": "{\"name\": \"response\", \"parameters\": {\"text\": \"Yaaay! 🎉 Check out the new 1/7 scale figure of me with Yoneyama Mai! Isn't it adorable? 🤩 Available now on meccha-japan! ❤️ - Miku 💕\"}}",
"attachments": [],
"reactions": []
}
]
}

View File

@@ -663,6 +663,7 @@
<button class="tab-button" onclick="switchTab('tab4')">🎨 Image Generation</button>
<button class="tab-button" onclick="switchTab('tab5')">📊 Autonomous Stats</button>
<button class="tab-button" onclick="switchTab('tab6')">💬 Chat with LLM</button>
<button class="tab-button" onclick="switchTab('tab7')">📞 Voice Call</button>
<button class="tab-button" onclick="window.location.href='/static/system.html'">🎛️ System Settings</button>
</div>
@@ -1374,6 +1375,112 @@
</div>
</div>
<!-- Tab 7: Voice Call Management -->
<div id="tab7" class="tab-content">
<div class="section">
<h3>📞 Initiate Voice Call</h3>
<p>Start an automated voice chat session with a user. Miku will automatically manage containers, join voice chat, and send an invitation DM.</p>
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1.5rem;">
<h4 style="margin-top: 0; color: #61dafb;">⚙️ Voice Call Configuration</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<!-- User ID Input -->
<div>
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">👤 Target User ID:</label>
<input
type="text"
id="voice-user-id"
placeholder="Discord user ID (e.g., 123456789)"
style="width: 100%; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px; box-sizing: border-box;"
>
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
Discord ID of the user to call
</div>
</div>
<!-- Voice Channel ID Input -->
<div>
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">🎤 Voice Channel ID:</label>
<input
type="text"
id="voice-channel-id"
placeholder="Discord channel ID (e.g., 987654321)"
style="width: 100%; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px; box-sizing: border-box;"
>
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
Discord ID of the voice channel to join
</div>
</div>
</div>
<!-- Debug Mode Toggle -->
<div style="margin-bottom: 1.5rem; padding: 1rem; background: #1e1e1e; border-radius: 4px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input
type="checkbox"
id="voice-debug-mode"
style="margin-right: 0.7rem; width: 18px; height: 18px; cursor: pointer;"
>
<span style="font-weight: bold;">🐛 Debug Mode</span>
</label>
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.5rem; margin-left: 1.7rem;">
When enabled, shows voice transcriptions and responses in text channel. When disabled, voice chat is private.
</div>
</div>
<!-- Call Status Display -->
<div id="voice-call-status" style="background: #1e1e1e; padding: 1rem; border-radius: 4px; margin-bottom: 1.5rem; display: none;">
<div style="color: #61dafb; font-weight: bold; margin-bottom: 0.5rem;">📊 Call Status:</div>
<div id="voice-call-status-text" style="color: #aaa; font-size: 0.9rem;"></div>
<div id="voice-call-invite-link" style="margin-top: 0.5rem; display: none;">
<strong>Invite Link:</strong> <a id="voice-call-invite-url" href="" target="_blank" style="color: #61dafb;">View Invite</a>
</div>
</div>
<!-- Call Buttons -->
<div style="display: flex; gap: 1rem;">
<button
id="voice-call-btn"
onclick="initiateVoiceCall()"
style="background: #2ecc71; color: #000; padding: 0.7rem 1.5rem; border: 1px solid #27ae60; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1rem;"
>
📞 Initiate Call
</button>
<button
id="voice-call-cancel-btn"
onclick="cancelVoiceCall()"
style="background: #e74c3c; color: #fff; padding: 0.7rem 1.5rem; border: 1px solid #c0392b; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1rem; display: none;"
>
🛑 Cancel Call
</button>
</div>
</div>
<!-- Call Information -->
<div style="background: #1a1a2e; padding: 1.5rem; border-radius: 8px; border-left: 3px solid #61dafb;">
<h4 style="margin-top: 0; color: #61dafb;"> How Voice Calls Work</h4>
<ul style="color: #ddd; line-height: 1.8;">
<li><strong>Automatic Setup:</strong> STT and TTS containers start automatically</li>
<li><strong>Warmup Wait:</strong> System waits for both containers to be ready (~30-75 seconds)</li>
<li><strong>VC Join:</strong> Miku joins the specified voice channel</li>
<li><strong>DM Invitation:</strong> User receives a personalized invite DM with a voice channel link</li>
<li><strong>Auto-Listen:</strong> STT automatically starts when user joins</li>
<li><strong>Auto-Leave:</strong> Miku leaves 45 seconds after user disconnects</li>
<li><strong>Timeout:</strong> If user doesn't join within 30 minutes, call is cancelled</li>
</ul>
</div>
<!-- Call History -->
<div style="margin-top: 2rem;">
<h4 style="color: #61dafb; margin-bottom: 1rem;">📋 Recent Calls</h4>
<div id="voice-call-history" style="background: #1e1e1e; border: 1px solid #444; border-radius: 4px; padding: 1rem;">
<div style="text-align: center; color: #888;">No calls yet. Start one above!</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1387,6 +1494,8 @@
<script>
// Global variables
let currentMood = 'neutral';
let voiceCallActive = false;
let voiceCallHistory = [];
let servers = [];
let evilMode = false;
@@ -4324,8 +4433,25 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
}
// Load voice debug mode setting
loadVoiceDebugMode();
});
// Load voice debug mode setting from server
async function loadVoiceDebugMode() {
try {
const response = await fetch('/voice/debug-mode');
const data = await response.json();
const checkbox = document.getElementById('voice-debug-mode');
if (checkbox && data.debug_mode !== undefined) {
checkbox.checked = data.debug_mode;
}
} catch (error) {
console.error('Failed to load voice debug mode:', error);
}
}
// Handle Enter key in chat input
function handleChatKeyPress(event) {
if (event.ctrlKey && event.key === 'Enter') {
@@ -4603,7 +4729,198 @@ function readFileAsBase64(file) {
});
}
// ============================================================================
// Voice Call Management Functions
// ============================================================================
async function initiateVoiceCall() {
const userId = document.getElementById('voice-user-id').value.trim();
const channelId = document.getElementById('voice-channel-id').value.trim();
const debugMode = document.getElementById('voice-debug-mode').checked;
// Validation
if (!userId) {
showNotification('Please enter a user ID', 'error');
return;
}
if (!channelId) {
showNotification('Please enter a voice channel ID', 'error');
return;
}
// Check if user IDs are valid (numeric)
if (isNaN(userId) || isNaN(channelId)) {
showNotification('User ID and Channel ID must be numeric', 'error');
return;
}
// Set debug mode
try {
const debugFormData = new FormData();
debugFormData.append('enabled', debugMode);
await fetch('/voice/debug-mode', {
method: 'POST',
body: debugFormData
});
} catch (error) {
console.error('Failed to set debug mode:', error);
}
// Disable button and show status
const callBtn = document.getElementById('voice-call-btn');
const cancelBtn = document.getElementById('voice-call-cancel-btn');
const statusDiv = document.getElementById('voice-call-status');
const statusText = document.getElementById('voice-call-status-text');
callBtn.disabled = true;
statusDiv.style.display = 'block';
cancelBtn.style.display = 'inline-block';
voiceCallActive = true;
try {
statusText.innerHTML = '⏳ Starting STT and TTS containers...';
const formData = new FormData();
formData.append('user_id', userId);
formData.append('voice_channel_id', channelId);
const response = await fetch('/voice/call', {
method: 'POST',
body: formData
});
const data = await response.json();
// Check for HTTP error status (422 validation error, etc.)
if (!response.ok) {
let errorMsg = data.error || data.detail || 'Unknown error';
// Handle FastAPI validation errors
if (data.detail && Array.isArray(data.detail)) {
errorMsg = data.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', ');
}
statusText.innerHTML = `❌ Error: ${errorMsg}`;
showNotification(`Voice call failed: ${errorMsg}`, 'error');
callBtn.disabled = false;
cancelBtn.style.display = 'none';
voiceCallActive = false;
return;
}
if (!data.success) {
statusText.innerHTML = `❌ Error: ${data.error}`;
showNotification(`Voice call failed: ${data.error}`, 'error');
callBtn.disabled = false;
cancelBtn.style.display = 'none';
voiceCallActive = false;
return;
}
// Success!
statusText.innerHTML = `✅ Voice call initiated!<br>User ID: ${data.user_id}<br>Channel: ${data.channel_id}`;
// Show invite link
const inviteDiv = document.getElementById('voice-call-invite-link');
const inviteUrl = document.getElementById('voice-call-invite-url');
inviteUrl.href = data.invite_url;
inviteUrl.textContent = data.invite_url;
inviteDiv.style.display = 'block';
// Add to call history
addVoiceCallToHistory(userId, channelId, data.invite_url);
showNotification('Voice call initiated successfully!', 'success');
// Auto-reset after 5 minutes (call should be done by then or timed out)
setTimeout(() => {
if (voiceCallActive) {
resetVoiceCall();
}
}, 300000); // 5 minutes
} catch (error) {
console.error('Voice call error:', error);
statusText.innerHTML = `❌ Error: ${error.message}`;
showNotification(`Voice call error: ${error.message}`, 'error');
callBtn.disabled = false;
cancelBtn.style.display = 'none';
voiceCallActive = false;
}
}
function cancelVoiceCall() {
resetVoiceCall();
showNotification('Voice call cancelled', 'info');
}
function resetVoiceCall() {
const callBtn = document.getElementById('voice-call-btn');
const cancelBtn = document.getElementById('voice-call-cancel-btn');
const statusDiv = document.getElementById('voice-call-status');
callBtn.disabled = false;
cancelBtn.style.display = 'none';
statusDiv.style.display = 'none';
voiceCallActive = false;
// Clear inputs
document.getElementById('voice-user-id').value = '';
document.getElementById('voice-channel-id').value = '';
}
function addVoiceCallToHistory(userId, channelId, inviteUrl) {
const now = new Date();
const timestamp = now.toLocaleTimeString();
const callEntry = {
userId: userId,
channelId: channelId,
inviteUrl: inviteUrl,
timestamp: timestamp
};
voiceCallHistory.unshift(callEntry); // Add to front
// Keep only last 10 calls
if (voiceCallHistory.length > 10) {
voiceCallHistory.pop();
}
updateVoiceCallHistoryDisplay();
}
function updateVoiceCallHistoryDisplay() {
const historyDiv = document.getElementById('voice-call-history');
if (voiceCallHistory.length === 0) {
historyDiv.innerHTML = '<div style="text-align: center; color: #888;">No calls yet. Start one above!</div>';
return;
}
let html = '';
voiceCallHistory.forEach((call, index) => {
html += `
<div style="background: #242424; padding: 0.75rem; margin-bottom: 0.5rem; border-radius: 4px; border-left: 3px solid #61dafb;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>${call.timestamp}</strong>
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
User: <code>${call.userId}</code> | Channel: <code>${call.channelId}</code>
</div>
</div>
<a href="${call.inviteUrl}" target="_blank" style="color: #61dafb; text-decoration: none; padding: 0.3rem 0.7rem; background: #333; border-radius: 4px; font-size: 0.85rem;">
View Link →
</a>
</div>
</div>
`;
});
historyDiv.innerHTML = html;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,205 @@
# container_manager.py
"""
Manages Docker containers for STT and TTS services.
Handles startup, shutdown, and warmup detection.
"""
import asyncio
import subprocess
import aiohttp
from utils.logger import get_logger
logger = get_logger('container_manager')
class ContainerManager:
"""Manages STT and TTS Docker containers."""
# Container names from docker-compose.yml
STT_CONTAINER = "miku-stt"
TTS_CONTAINER = "miku-rvc-api"
# Warmup check endpoints
STT_HEALTH_URL = "http://miku-stt:8767/health" # HTTP health check endpoint
TTS_HEALTH_URL = "http://miku-rvc-api:8765/health"
# Warmup timeouts
STT_WARMUP_TIMEOUT = 30 # seconds
TTS_WARMUP_TIMEOUT = 60 # seconds (RVC takes longer)
@classmethod
async def start_voice_containers(cls) -> bool:
"""
Start STT and TTS containers and wait for them to warm up.
Returns:
bool: True if both containers started and warmed up successfully
"""
logger.info("🚀 Starting voice chat containers...")
try:
# Start STT container using docker start (assumes container exists)
logger.info(f"Starting {cls.STT_CONTAINER}...")
result = subprocess.run(
["docker", "start", cls.STT_CONTAINER],
capture_output=True,
text=True
)
if result.returncode != 0:
logger.error(f"Failed to start {cls.STT_CONTAINER}: {result.stderr}")
return False
logger.info(f"{cls.STT_CONTAINER} started")
# Start TTS container
logger.info(f"Starting {cls.TTS_CONTAINER}...")
result = subprocess.run(
["docker", "start", cls.TTS_CONTAINER],
capture_output=True,
text=True
)
if result.returncode != 0:
logger.error(f"Failed to start {cls.TTS_CONTAINER}: {result.stderr}")
return False
logger.info(f"{cls.TTS_CONTAINER} started")
# Wait for warmup
logger.info("⏳ Waiting for containers to warm up...")
stt_ready = await cls._wait_for_stt_warmup()
if not stt_ready:
logger.error("STT failed to warm up")
return False
tts_ready = await cls._wait_for_tts_warmup()
if not tts_ready:
logger.error("TTS failed to warm up")
return False
logger.info("✅ All voice containers ready!")
return True
except Exception as e:
logger.error(f"Error starting voice containers: {e}")
return False
@classmethod
async def stop_voice_containers(cls) -> bool:
"""
Stop STT and TTS containers.
Returns:
bool: True if containers stopped successfully
"""
logger.info("🛑 Stopping voice chat containers...")
try:
# Stop both containers
result = subprocess.run(
["docker", "stop", cls.STT_CONTAINER, cls.TTS_CONTAINER],
capture_output=True,
text=True
)
if result.returncode != 0:
logger.error(f"Failed to stop containers: {result.stderr}")
return False
logger.info("✓ Voice containers stopped")
return True
except Exception as e:
logger.error(f"Error stopping voice containers: {e}")
return False
@classmethod
async def _wait_for_stt_warmup(cls) -> bool:
"""
Wait for STT container to be ready by checking health endpoint.
Returns:
bool: True if STT is ready within timeout
"""
start_time = asyncio.get_event_loop().time()
async with aiohttp.ClientSession() as session:
while (asyncio.get_event_loop().time() - start_time) < cls.STT_WARMUP_TIMEOUT:
try:
async with session.get(cls.STT_HEALTH_URL, timeout=aiohttp.ClientTimeout(total=2)) as resp:
if resp.status == 200:
data = await resp.json()
if data.get("status") == "ready" and data.get("warmed_up"):
logger.info("✓ STT is ready")
return True
except Exception:
# Not ready yet, wait and retry
pass
await asyncio.sleep(2)
logger.error(f"STT warmup timeout ({cls.STT_WARMUP_TIMEOUT}s)")
return False
@classmethod
async def _wait_for_tts_warmup(cls) -> bool:
"""
Wait for TTS container to be ready by checking health endpoint.
Returns:
bool: True if TTS is ready within timeout
"""
start_time = asyncio.get_event_loop().time()
async with aiohttp.ClientSession() as session:
while (asyncio.get_event_loop().time() - start_time) < cls.TTS_WARMUP_TIMEOUT:
try:
async with session.get(cls.TTS_HEALTH_URL, timeout=aiohttp.ClientTimeout(total=2)) as resp:
if resp.status == 200:
data = await resp.json()
# RVC API returns "status": "healthy", not "ready"
status_ok = data.get("status") in ["ready", "healthy"]
if status_ok and data.get("warmed_up"):
logger.info("✓ TTS is ready")
return True
except Exception:
# Not ready yet, wait and retry
pass
await asyncio.sleep(2)
logger.error(f"TTS warmup timeout ({cls.TTS_WARMUP_TIMEOUT}s)")
return False
return False
@classmethod
async def are_containers_running(cls) -> tuple[bool, bool]:
"""
Check if STT and TTS containers are currently running.
Returns:
tuple[bool, bool]: (stt_running, tts_running)
"""
try:
# Check STT
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", cls.STT_CONTAINER],
capture_output=True,
text=True
)
stt_running = result.returncode == 0 and result.stdout.strip() == "true"
# Check TTS
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", cls.TTS_CONTAINER],
capture_output=True,
text=True
)
tts_running = result.returncode == 0 and result.stdout.strip() == "true"
return (stt_running, tts_running)
except Exception as e:
logger.error(f"Error checking container status: {e}")
return (False, False)

View File

@@ -62,6 +62,7 @@ COMPONENTS = {
'voice_manager': 'Voice channel session management',
'voice_commands': 'Voice channel commands',
'voice_audio': 'Voice audio streaming and TTS',
'container_manager': 'Docker container lifecycle management',
'error_handler': 'Error detection and webhook notifications',
}

View File

@@ -1,11 +1,15 @@
"""
STT Client for Discord Bot
STT Client for Discord Bot (RealtimeSTT Version)
WebSocket client that connects to the STT server and handles:
WebSocket client that connects to the RealtimeSTT server and handles:
- Audio streaming to STT
- Receiving VAD events
- Receiving partial/final transcripts
- Interruption detection
Protocol:
- Client sends: binary audio data (16kHz, 16-bit mono PCM)
- Client sends: JSON {"command": "reset"} to reset state
- Server sends: JSON {"type": "partial", "text": "...", "timestamp": float}
- Server sends: JSON {"type": "final", "text": "...", "timestamp": float}
"""
import aiohttp
@@ -19,7 +23,7 @@ logger = logging.getLogger('stt_client')
class STTClient:
"""
WebSocket client for STT server communication.
WebSocket client for RealtimeSTT server communication.
Handles audio streaming and receives transcription events.
"""
@@ -27,34 +31,28 @@ class STTClient:
def __init__(
self,
user_id: str,
stt_url: str = "ws://miku-stt:8766/ws/stt",
on_vad_event: Optional[Callable] = None,
stt_url: str = "ws://miku-stt:8766",
on_partial_transcript: Optional[Callable] = None,
on_final_transcript: Optional[Callable] = None,
on_interruption: Optional[Callable] = None
):
"""
Initialize STT client.
Args:
user_id: Discord user ID
stt_url: Base WebSocket URL for STT server
on_vad_event: Callback for VAD events (event_dict)
user_id: Discord user ID (for logging purposes)
stt_url: WebSocket URL for STT server
on_partial_transcript: Callback for partial transcripts (text, timestamp)
on_final_transcript: Callback for final transcripts (text, timestamp)
on_interruption: Callback for interruption detection (probability)
"""
self.user_id = user_id
self.stt_url = f"{stt_url}/{user_id}"
self.stt_url = stt_url
# Callbacks
self.on_vad_event = on_vad_event
self.on_partial_transcript = on_partial_transcript
self.on_final_transcript = on_final_transcript
self.on_interruption = on_interruption
# Connection state
self.websocket: Optional[aiohttp.ClientWebSocket] = None
self.websocket: Optional[aiohttp.ClientWebSocketResponse] = None
self.session: Optional[aiohttp.ClientSession] = None
self.connected = False
self.running = False
@@ -65,7 +63,7 @@ class STTClient:
logger.info(f"STT client initialized for user {user_id}")
async def connect(self):
"""Connect to STT WebSocket server."""
"""Connect to RealtimeSTT WebSocket server."""
if self.connected:
logger.warning(f"Already connected for user {self.user_id}")
return
@@ -74,202 +72,156 @@ class STTClient:
self.session = aiohttp.ClientSession()
self.websocket = await self.session.ws_connect(
self.stt_url,
heartbeat=30
heartbeat=30,
receive_timeout=60
)
# Wait for ready message
ready_msg = await self.websocket.receive_json()
logger.info(f"STT connected for user {self.user_id}: {ready_msg}")
self.connected = True
self.running = True
# Start receive task
self._receive_task = asyncio.create_task(self._receive_events())
# Start background task to receive messages
self._receive_task = asyncio.create_task(self._receive_loop())
logger.info(f"✓ STT WebSocket connected for user {self.user_id}")
logger.info(f"Connected to STT server at {self.stt_url} for user {self.user_id}")
except Exception as e:
logger.error(f"Failed to connect STT for user {self.user_id}: {e}", exc_info=True)
await self.disconnect()
logger.error(f"Failed to connect to STT server: {e}")
await self._cleanup()
raise
async def disconnect(self):
"""Disconnect from STT WebSocket."""
logger.info(f"Disconnecting STT for user {self.user_id}")
"""Disconnect from STT server."""
self.running = False
self.connected = False
# Cancel receive task
if self._receive_task and not self._receive_task.done():
if self._receive_task:
self._receive_task.cancel()
try:
await self._receive_task
except asyncio.CancelledError:
pass
self._receive_task = None
# Close WebSocket
await self._cleanup()
logger.info(f"Disconnected from STT server for user {self.user_id}")
async def _cleanup(self):
"""Clean up WebSocket and session."""
if self.websocket:
await self.websocket.close()
try:
await self.websocket.close()
except Exception:
pass
self.websocket = None
# Close session
if self.session:
await self.session.close()
try:
await self.session.close()
except Exception:
pass
self.session = None
logger.info(f"✓ STT disconnected for user {self.user_id}")
self.connected = False
async def send_audio(self, audio_data: bytes):
"""
Send audio chunk to STT server.
Send raw audio data to STT server.
Args:
audio_data: PCM audio (int16, 16kHz mono)
audio_data: Raw PCM audio (16kHz, 16-bit mono, little-endian)
"""
if not self.connected or not self.websocket:
logger.warning(f"Cannot send audio, not connected for user {self.user_id}")
return
try:
await self.websocket.send_bytes(audio_data)
logger.debug(f"Sent {len(audio_data)} bytes to STT")
except Exception as e:
logger.error(f"Failed to send audio to STT: {e}")
self.connected = False
logger.error(f"Failed to send audio: {e}")
await self._cleanup()
async def send_final(self):
"""
Request final transcription from STT server.
Call this when the user stops speaking to get the final transcript.
"""
async def reset(self):
"""Reset STT state (clear any pending transcription)."""
if not self.connected or not self.websocket:
logger.warning(f"Cannot send final command, not connected for user {self.user_id}")
return
try:
command = json.dumps({"type": "final"})
await self.websocket.send_str(command)
logger.debug(f"Sent final command to STT")
await self.websocket.send_json({"command": "reset"})
logger.debug(f"Sent reset command for user {self.user_id}")
except Exception as e:
logger.error(f"Failed to send final command to STT: {e}")
self.connected = False
logger.error(f"Failed to send reset: {e}")
async def send_reset(self):
"""
Reset the STT server's audio buffer.
Call this to clear any buffered audio.
"""
if not self.connected or not self.websocket:
logger.warning(f"Cannot send reset command, not connected for user {self.user_id}")
return
try:
command = json.dumps({"type": "reset"})
await self.websocket.send_str(command)
logger.debug(f"Sent reset command to STT")
except Exception as e:
logger.error(f"Failed to send reset command to STT: {e}")
self.connected = False
def is_connected(self) -> bool:
"""Check if connected to STT server."""
return self.connected and self.websocket is not None
async def _receive_events(self):
"""Background task to receive events from STT server."""
async def _receive_loop(self):
"""Background task to receive messages from STT server."""
try:
while self.running and self.websocket:
try:
msg = await self.websocket.receive()
msg = await asyncio.wait_for(
self.websocket.receive(),
timeout=5.0
)
if msg.type == aiohttp.WSMsgType.TEXT:
event = json.loads(msg.data)
await self._handle_event(event)
await self._handle_message(msg.data)
elif msg.type == aiohttp.WSMsgType.CLOSED:
logger.info(f"STT WebSocket closed for user {self.user_id}")
logger.warning(f"STT WebSocket closed for user {self.user_id}")
break
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.error(f"STT WebSocket error for user {self.user_id}")
break
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error receiving STT event: {e}", exc_info=True)
except asyncio.TimeoutError:
# Timeout is fine, just continue
continue
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Error in STT receive loop: {e}")
finally:
self.connected = False
logger.info(f"STT receive task ended for user {self.user_id}")
async def _handle_event(self, event: dict):
"""
Handle incoming STT event.
Args:
event: Event dictionary from STT server
"""
event_type = event.get('type')
if event_type == 'transcript':
# New ONNX server protocol: single transcript type with is_final flag
text = event.get('text', '')
is_final = event.get('is_final', False)
timestamp = event.get('timestamp', 0)
async def _handle_message(self, data: str):
"""Handle a message from the STT server."""
try:
message = json.loads(data)
msg_type = message.get("type")
text = message.get("text", "")
timestamp = message.get("timestamp", 0)
if is_final:
logger.info(f"Final transcript [{self.user_id}]: {text}")
if self.on_final_transcript:
await self.on_final_transcript(text, timestamp)
else:
logger.info(f"Partial transcript [{self.user_id}]: {text}")
if self.on_partial_transcript:
await self.on_partial_transcript(text, timestamp)
elif event_type == 'vad':
# VAD event: speech detection (legacy support)
logger.debug(f"VAD event: {event}")
if self.on_vad_event:
await self.on_vad_event(event)
elif event_type == 'partial':
# Legacy protocol support: partial transcript
text = event.get('text', '')
timestamp = event.get('timestamp', 0)
logger.info(f"Partial transcript [{self.user_id}]: {text}")
if self.on_partial_transcript:
await self.on_partial_transcript(text, timestamp)
elif event_type == 'final':
# Legacy protocol support: final transcript
text = event.get('text', '')
timestamp = event.get('timestamp', 0)
logger.info(f"Final transcript [{self.user_id}]: {text}")
if self.on_final_transcript:
await self.on_final_transcript(text, timestamp)
elif event_type == 'interruption':
# Interruption detected (legacy support)
probability = event.get('probability', 0)
logger.info(f"Interruption detected from user {self.user_id} (prob={probability:.3f})")
if self.on_interruption:
await self.on_interruption(probability)
elif event_type == 'info':
# Info message
logger.info(f"STT info: {event.get('message', '')}")
elif event_type == 'error':
# Error message
logger.error(f"STT error: {event.get('message', '')}")
else:
logger.warning(f"Unknown STT event type: {event_type}")
if msg_type == "partial":
if self.on_partial_transcript and text:
await self._call_callback(
self.on_partial_transcript,
text,
timestamp
)
elif msg_type == "final":
if self.on_final_transcript and text:
await self._call_callback(
self.on_final_transcript,
text,
timestamp
)
elif msg_type == "connected":
logger.info(f"STT server confirmed connection for user {self.user_id}")
elif msg_type == "error":
error_msg = message.get("error", "Unknown error")
logger.error(f"STT server error: {error_msg}")
except json.JSONDecodeError:
logger.warning(f"Invalid JSON from STT server: {data[:100]}")
except Exception as e:
logger.error(f"Error handling STT message: {e}")
def is_connected(self) -> bool:
"""Check if STT client is connected."""
return self.connected
async def _call_callback(self, callback, *args):
"""Safely call a callback, handling both sync and async functions."""
try:
result = callback(*args)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error(f"Error in STT callback: {e}")

View File

@@ -6,6 +6,7 @@ Uses aiohttp for WebSocket communication (compatible with FastAPI).
import asyncio
import json
import re
import numpy as np
from typing import Optional
import discord
@@ -29,6 +30,25 @@ CHANNELS = 2 # Stereo for Discord
FRAME_LENGTH = 0.02 # 20ms frames
SAMPLES_PER_FRAME = int(SAMPLE_RATE * FRAME_LENGTH) # 960 samples
# Emoji pattern for filtering
# Covers most emoji ranges including emoticons, symbols, pictographs, etc.
EMOJI_PATTERN = re.compile(
"["
"\U0001F600-\U0001F64F" # emoticons
"\U0001F300-\U0001F5FF" # symbols & pictographs
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F1E0-\U0001F1FF" # flags (iOS)
"\U00002702-\U000027B0" # dingbats
"\U000024C2-\U0001F251" # enclosed characters
"\U0001F900-\U0001F9FF" # supplemental symbols and pictographs
"\U0001FA00-\U0001FA6F" # chess symbols
"\U0001FA70-\U0001FAFF" # symbols and pictographs extended-A
"\U00002600-\U000026FF" # miscellaneous symbols
"\U00002700-\U000027BF" # dingbats
"]+",
flags=re.UNICODE
)
class MikuVoiceSource(discord.AudioSource):
"""
@@ -38,8 +58,9 @@ class MikuVoiceSource(discord.AudioSource):
"""
def __init__(self):
self.websocket_url = "ws://172.25.0.1:8765/ws/stream"
self.health_url = "http://172.25.0.1:8765/health"
# Use Docker hostname for RVC service (miku-rvc-api is on miku-voice-network)
self.websocket_url = "ws://miku-rvc-api:8765/ws/stream"
self.health_url = "http://miku-rvc-api:8765/health"
self.session = None
self.websocket = None
self.audio_buffer = bytearray()
@@ -230,11 +251,26 @@ class MikuVoiceSource(discord.AudioSource):
"""
Send a text token to TTS for voice generation.
Queues tokens if pipeline is still warming up or connection failed.
Filters out emojis to prevent TTS hallucinations.
Args:
token: Text token to synthesize
pitch_shift: Pitch adjustment (-12 to +12 semitones)
"""
# Filter out emojis from the token (preserve whitespace!)
original_token = token
token = EMOJI_PATTERN.sub('', token)
# If token is now empty or only whitespace after emoji removal, skip it
if not token or not token.strip():
if original_token != token:
logger.debug(f"Skipped token (only emojis): '{original_token}'")
return
# Log if we filtered out emojis
if original_token != token:
logger.debug(f"Filtered emojis from token: '{original_token}' -> '{token}'")
# If not warmed up yet or no connection, queue the token
if not self.warmed_up or not self.websocket:
self.token_queue.append((token, pitch_shift))

View File

@@ -398,6 +398,13 @@ class VoiceSession:
# Voice chat conversation history (last 8 exchanges)
self.conversation_history = [] # List of {"role": "user"/"assistant", "content": str}
# Voice call management (for automated calls from web UI)
self.call_user_id: Optional[int] = None # User ID that was called
self.call_timeout_task: Optional[asyncio.Task] = None # 30min timeout task
self.user_has_joined = False # Track if user joined the call
self.auto_leave_task: Optional[asyncio.Task] = None # 45s auto-leave task
self.user_leave_time: Optional[float] = None # When user left the channel
logger.info(f"VoiceSession created for {voice_channel.name} in guild {guild_id}")
async def start_audio_streaming(self):
@@ -488,6 +495,57 @@ class VoiceSession:
self.voice_receiver = None
logger.info("✓ Stopped all listening")
async def on_user_join(self, user_id: int):
"""Called when a user joins the voice channel."""
# If this is a voice call and the expected user joined
if self.call_user_id and user_id == self.call_user_id:
self.user_has_joined = True
logger.info(f"✓ Call user {user_id} joined the channel")
# Cancel timeout task since user joined
if self.call_timeout_task:
self.call_timeout_task.cancel()
self.call_timeout_task = None
# Cancel auto-leave task if it was running
if self.auto_leave_task:
self.auto_leave_task.cancel()
self.auto_leave_task = None
self.user_leave_time = None
async def on_user_leave(self, user_id: int):
"""Called when a user leaves the voice channel."""
# If this is the call user leaving
if self.call_user_id and user_id == self.call_user_id and self.user_has_joined:
import time
self.user_leave_time = time.time()
logger.info(f"📴 Call user {user_id} left - starting 45s auto-leave timer")
# Start 45s auto-leave timer
self.auto_leave_task = asyncio.create_task(self._auto_leave_after_user_disconnect())
async def _auto_leave_after_user_disconnect(self):
"""Auto-leave 45s after user disconnects."""
try:
await asyncio.sleep(45)
logger.info("⏰ 45s timeout reached - auto-leaving voice channel")
# End the session (will trigger cleanup)
from utils.voice_manager import VoiceSessionManager
session_manager = VoiceSessionManager()
await session_manager.end_session()
# Stop containers
from utils.container_manager import ContainerManager
await ContainerManager.stop_voice_containers()
logger.info("✓ Auto-leave complete")
except asyncio.CancelledError:
# User rejoined, normal operation
logger.info("Auto-leave cancelled - user rejoined")
async def on_user_vad_event(self, user_id: int, event: dict):
"""Called when VAD detects speech state change."""
event_type = event.get('event')
@@ -515,7 +573,10 @@ class VoiceSession:
# Get user info for notification
user = self.voice_channel.guild.get_member(user_id)
user_name = user.name if user else f"User {user_id}"
await self.text_channel.send(f"💬 *{user_name} said: \"{text}\" (interrupted but too brief - talk longer to interrupt)*")
# Only send message if debug mode is on
if globals.VOICE_DEBUG_MODE:
await self.text_channel.send(f"💬 *{user_name} said: \"{text}\" (interrupted but too brief - talk longer to interrupt)*")
return
logger.info(f"✓ Processing final transcript (miku_speaking={self.miku_speaking})")
@@ -530,12 +591,14 @@ class VoiceSession:
stop_phrases = ["stop talking", "be quiet", "shut up", "stop speaking", "silence"]
if any(phrase in text.lower() for phrase in stop_phrases):
logger.info(f"🤫 Stop command detected: {text}")
await self.text_channel.send(f"🎤 {user.name}: *\"{text}\"*")
await self.text_channel.send(f"🤫 *Miku goes quiet*")
if globals.VOICE_DEBUG_MODE:
await self.text_channel.send(f"🎤 {user.name}: *\"{text}\"*")
await self.text_channel.send(f"🤫 *Miku goes quiet*")
return
# Show what user said
await self.text_channel.send(f"🎤 {user.name}: *\"{text}\"*")
# Show what user said (only in debug mode)
if globals.VOICE_DEBUG_MODE:
await self.text_channel.send(f"🎤 {user.name}: *\"{text}\"*")
# Generate LLM response and speak it
await self._generate_voice_response(user, text)
@@ -582,14 +645,15 @@ class VoiceSession:
logger.info(f"⏸️ Pausing for {self.interruption_silence_duration}s after interruption")
await asyncio.sleep(self.interruption_silence_duration)
# 5. Add interruption marker to conversation history
# Add interruption marker to conversation history
self.conversation_history.append({
"role": "assistant",
"content": "[INTERRUPTED - user started speaking]"
})
# Show interruption in chat
await self.text_channel.send(f"⚠️ *{user_name} interrupted Miku*")
# Show interruption in chat (only in debug mode)
if globals.VOICE_DEBUG_MODE:
await self.text_channel.send(f"⚠️ *{user_name} interrupted Miku*")
logger.info(f"✓ Interruption handled, ready for next input")
@@ -599,8 +663,10 @@ class VoiceSession:
Called when VAD-based interruption detection is used.
"""
await self.on_user_interruption(user_id)
user = self.voice_channel.guild.get_member(user_id)
await self.text_channel.send(f"⚠️ *{user.name if user else 'User'} interrupted Miku*")
# Only show interruption message in debug mode
if globals.VOICE_DEBUG_MODE:
user = self.voice_channel.guild.get_member(user_id)
await self.text_channel.send(f"⚠️ *{user.name if user else 'User'} interrupted Miku*")
async def _generate_voice_response(self, user: discord.User, text: str):
"""
@@ -624,13 +690,13 @@ class VoiceSession:
self.miku_speaking = True
logger.info(f" → miku_speaking is now: {self.miku_speaking}")
# Show processing
await self.text_channel.send(f"💭 *Miku is thinking...*")
# Show processing (only in debug mode)
if globals.VOICE_DEBUG_MODE:
await self.text_channel.send(f"💭 *Miku is thinking...*")
# Import here to avoid circular imports
from utils.llm import get_current_gpu_url
import aiohttp
import globals
# Load personality and lore
miku_lore = ""
@@ -657,8 +723,11 @@ VOICE CHAT CONTEXT:
* Stories/explanations: 4-6 sentences when asked for details
- Match the user's energy and conversation style
- IMPORTANT: Only respond in ENGLISH! The TTS system cannot handle Japanese or other languages well.
- IMPORTANT: Do not include emojis in your response! The TTS system cannot handle them well.
- IMPORTANT: Do NOT prefix your response with your name (like "Miku:" or "Hatsune Miku:")! Just speak naturally - you're already known to be speaking.
- Be expressive and use casual language, but stay in character as Miku
- If user says "stop talking" or "be quiet", acknowledge briefly and stop
- NOTE: You will automatically disconnect 45 seconds after {user.name} leaves the voice channel, so you can mention this if asked about leaving
Remember: This is a live voice conversation - be natural, not formulaic!"""
@@ -742,15 +811,19 @@ Remember: This is a live voice conversation - be natural, not formulaic!"""
if self.miku_speaking:
await self.audio_source.flush()
# Add Miku's complete response to history
# Filter out self-referential prefixes from response
filtered_response = self._filter_name_prefixes(full_response.strip())
# Add Miku's complete response to history (use filtered version)
self.conversation_history.append({
"role": "assistant",
"content": full_response.strip()
"content": filtered_response
})
# Show response
await self.text_channel.send(f"🎤 Miku: *\"{full_response.strip()}\"*")
logger.info(f"✓ Voice response complete: {full_response.strip()}")
# Show response (only in debug mode)
if globals.VOICE_DEBUG_MODE:
await self.text_channel.send(f"🎤 Miku: *\"{filtered_response}\"*")
logger.info(f"✓ Voice response complete: {filtered_response}")
else:
# Interrupted - don't add incomplete response to history
# (interruption marker already added by on_user_interruption)
@@ -763,6 +836,35 @@ Remember: This is a live voice conversation - be natural, not formulaic!"""
finally:
self.miku_speaking = False
def _filter_name_prefixes(self, text: str) -> str:
"""
Filter out self-referential name prefixes from Miku's responses.
Removes patterns like:
- "Miku: rest of text"
- "Hatsune Miku: rest of text"
- "miku: rest of text" (case insensitive)
Args:
text: Raw response text
Returns:
Filtered text without name prefixes
"""
import re
# Pattern matches "Miku:" or "Hatsune Miku:" at the start of the text (case insensitive)
# Captures any amount of whitespace after the colon
pattern = r'^(?:Hatsune\s+)?Miku:\s*'
filtered = re.sub(pattern, '', text, flags=re.IGNORECASE)
# Log if we filtered something
if filtered != text:
logger.info(f"Filtered name prefix: '{text[:30]}...' -> '{filtered[:30]}...'")
return filtered
async def _cancel_tts(self):
"""
Immediately cancel TTS synthesis and clear all audio buffers.

View File

@@ -8,6 +8,8 @@ Uses the discord-ext-voice-recv extension for proper audio receiving support.
import asyncio
import audioop
import logging
import struct
import array
from typing import Dict, Optional
from collections import deque
@@ -27,13 +29,13 @@ class VoiceReceiverSink(voice_recv.AudioSink):
decodes/resamples as needed, and sends to STT clients for transcription.
"""
def __init__(self, voice_manager, stt_url: str = "ws://miku-stt:8766/ws/stt"):
def __init__(self, voice_manager, stt_url: str = "ws://miku-stt:8766"):
"""
Initialize Voice Receiver.
Args:
voice_manager: The voice manager instance
stt_url: Base URL for STT WebSocket server with path (port 8766 inside container)
stt_url: WebSocket URL for RealtimeSTT server (port 8766 inside container)
"""
super().__init__()
self.voice_manager = voice_manager
@@ -72,6 +74,68 @@ class VoiceReceiverSink(voice_recv.AudioSink):
logger.info("VoiceReceiverSink initialized")
@staticmethod
def _preprocess_audio(pcm_data: bytes) -> bytes:
"""
Preprocess audio for better STT accuracy.
Applies:
1. DC offset removal
2. High-pass filter (80Hz) to remove rumble
3. RMS normalization
Args:
pcm_data: Raw PCM audio (16-bit mono, 16kHz)
Returns:
Preprocessed PCM audio
"""
try:
# Convert bytes to array of int16 samples
samples = array.array('h', pcm_data)
# 1. Remove DC offset (mean)
mean = sum(samples) / len(samples) if samples else 0
samples = array.array('h', [int(s - mean) for s in samples])
# 2. Simple high-pass filter (80Hz @ 16kHz)
# Using a simple first-order HPF: y[n] = x[n] - x[n-1] + 0.95 * y[n-1]
alpha = 0.95 # Filter coefficient (roughly 80Hz cutoff at 16kHz)
filtered = array.array('h')
prev_input = 0
prev_output = 0
for sample in samples:
output = sample - prev_input + alpha * prev_output
filtered.append(int(max(-32768, min(32767, output)))) # Clamp to int16 range
prev_input = sample
prev_output = output
# 3. RMS normalization to target level
# Calculate RMS
sum_squares = sum(s * s for s in filtered)
rms = (sum_squares / len(filtered)) ** 0.5 if filtered else 1.0
# Target RMS (roughly -20dB)
target_rms = 3276.8 # 10% of max int16 range
# Normalize if RMS is too low or too high
if rms > 100: # Only normalize if there's actual signal
gain = target_rms / rms
# Limit gain to prevent over-amplification of noise
gain = min(gain, 4.0) # Max 12dB boost
normalized = array.array('h', [
int(max(-32768, min(32767, s * gain))) for s in filtered
])
return normalized.tobytes()
else:
# Signal too weak, return filtered without normalization
return filtered.tobytes()
except Exception as e:
logger.debug(f"Audio preprocessing failed, using raw audio: {e}")
return pcm_data
def wants_opus(self) -> bool:
"""
Tell discord-ext-voice-recv we want Opus data, NOT decoded PCM.
@@ -144,6 +208,10 @@ class VoiceReceiverSink(voice_recv.AudioSink):
# Discord sends 20ms chunks: 960 samples @ 48kHz → 320 samples @ 16kHz
pcm_16k, _ = audioop.ratecv(pcm_mono, 2, 1, 48000, 16000, None)
# Preprocess audio for better STT accuracy
# (DC offset removal, high-pass filter, RMS normalization)
pcm_16k = self._preprocess_audio(pcm_16k)
# Send to STT client (schedule on event loop thread-safely)
asyncio.run_coroutine_threadsafe(
self._send_audio_chunk(user_id, pcm_16k),
@@ -184,21 +252,16 @@ class VoiceReceiverSink(voice_recv.AudioSink):
self.audio_buffers[user_id] = deque(maxlen=1000)
# Create STT client with callbacks
# RealtimeSTT handles VAD internally, so we only need partial/final callbacks
stt_client = STTClient(
user_id=user_id,
stt_url=self.stt_url,
on_vad_event=lambda event: asyncio.create_task(
self._on_vad_event(user_id, event)
),
on_partial_transcript=lambda text, timestamp: asyncio.create_task(
self._on_partial_transcript(user_id, text)
),
on_final_transcript=lambda text, timestamp: asyncio.create_task(
self._on_final_transcript(user_id, text, user)
),
on_interruption=lambda prob: asyncio.create_task(
self._on_interruption(user_id, prob)
)
)
# Connect to STT server
@@ -279,16 +342,16 @@ class VoiceReceiverSink(voice_recv.AudioSink):
"""
Send audio chunk to STT client.
Buffers audio until we have 512 samples (32ms @ 16kHz) which is what
Silero VAD expects. Discord sends 320 samples (20ms), so we buffer
2 chunks and send 640 samples, then the STT server can split it.
RealtimeSTT expects 16kHz mono 16-bit PCM audio.
We buffer audio to send larger chunks for efficiency.
VAD and silence detection is handled by RealtimeSTT.
Args:
user_id: Discord user ID
audio_data: PCM audio (int16, 16kHz mono, 320 samples = 640 bytes)
audio_data: PCM audio (int16, 16kHz mono)
"""
stt_client = self.stt_clients.get(user_id)
if not stt_client or not stt_client.is_connected():
if not stt_client or not stt_client.connected:
return
try:
@@ -299,11 +362,9 @@ class VoiceReceiverSink(voice_recv.AudioSink):
buffer = self.audio_buffers[user_id]
buffer.append(audio_data)
# Silero VAD expects 512 samples @ 16kHz (1024 bytes)
# Discord gives us 320 samples (640 bytes) every 20ms
# Buffer 2 chunks = 640 samples = 1280 bytes, send as one chunk
SAMPLES_NEEDED = 512 # What VAD wants
BYTES_NEEDED = SAMPLES_NEEDED * 2 # int16 = 2 bytes per sample
# Buffer and send in larger chunks for efficiency
# RealtimeSTT will handle VAD internally
BYTES_NEEDED = 1024 # 512 samples * 2 bytes
# Check if we have enough buffered audio
total_bytes = sum(len(chunk) for chunk in buffer)
@@ -313,16 +374,10 @@ class VoiceReceiverSink(voice_recv.AudioSink):
combined = b''.join(buffer)
buffer.clear()
# Send in 512-sample (1024-byte) chunks
for i in range(0, len(combined), BYTES_NEEDED):
chunk = combined[i:i+BYTES_NEEDED]
if len(chunk) == BYTES_NEEDED:
await stt_client.send_audio(chunk)
else:
# Put remaining partial chunk back in buffer
buffer.append(chunk)
# Send all audio to STT (RealtimeSTT handles VAD internally)
await stt_client.send_audio(combined)
# Track audio time for silence detection
# Track audio time for interruption detection
import time
current_time = time.time()
self.last_audio_time[user_id] = current_time
@@ -331,103 +386,57 @@ class VoiceReceiverSink(voice_recv.AudioSink):
# Check if Miku is speaking and user is interrupting
# Note: self.voice_manager IS the VoiceSession, not the VoiceManager singleton
miku_speaking = self.voice_manager.miku_speaking
logger.debug(f"[INTERRUPTION CHECK] user={user_id}, miku_speaking={miku_speaking}")
if miku_speaking:
# Track interruption
if user_id not in self.interruption_start_time:
# First chunk during Miku's speech
self.interruption_start_time[user_id] = current_time
self.interruption_audio_count[user_id] = 1
# Calculate RMS to detect if user is actually speaking
# (not just silence/background noise)
rms = audioop.rms(combined, 2)
RMS_THRESHOLD = 500 # Adjust threshold - higher = less sensitive
if rms > RMS_THRESHOLD:
# User is actually speaking - track as potential interruption
if user_id not in self.interruption_start_time:
# First chunk during Miku's speech with actual audio
self.interruption_start_time[user_id] = current_time
self.interruption_audio_count[user_id] = 1
logger.debug(f"Potential interruption start (rms={rms})")
else:
# Increment chunk count
self.interruption_audio_count[user_id] += 1
# Calculate interruption duration
interruption_duration = current_time - self.interruption_start_time[user_id]
chunk_count = self.interruption_audio_count[user_id]
# Check if interruption threshold is met
if (interruption_duration >= self.interruption_threshold_time and
chunk_count >= self.interruption_threshold_chunks):
# Trigger interruption!
logger.info(f"🛑 User {user_id} interrupted Miku (duration={interruption_duration:.2f}s, chunks={chunk_count}, rms={rms})")
logger.info(f" → Stopping Miku's TTS and LLM, will process user's speech when finished")
# Reset interruption tracking
self.interruption_start_time.pop(user_id, None)
self.interruption_audio_count.pop(user_id, None)
# Call interruption handler (this sets miku_speaking=False)
asyncio.create_task(
self.voice_manager.on_user_interruption(user_id)
)
else:
# Increment chunk count
self.interruption_audio_count[user_id] += 1
# Calculate interruption duration
interruption_duration = current_time - self.interruption_start_time[user_id]
chunk_count = self.interruption_audio_count[user_id]
# Check if interruption threshold is met
if (interruption_duration >= self.interruption_threshold_time and
chunk_count >= self.interruption_threshold_chunks):
# Trigger interruption!
logger.info(f"🛑 User {user_id} interrupted Miku (duration={interruption_duration:.2f}s, chunks={chunk_count})")
logger.info(f" → Stopping Miku's TTS and LLM, will process user's speech when finished")
# Reset interruption tracking
# Audio below RMS threshold (silence) - reset interruption tracking
# This ensures brief pauses in speech reset the counter
self.interruption_start_time.pop(user_id, None)
self.interruption_audio_count.pop(user_id, None)
# Call interruption handler (this sets miku_speaking=False)
asyncio.create_task(
self.voice_manager.on_user_interruption(user_id)
)
else:
# Miku not speaking, clear interruption tracking
self.interruption_start_time.pop(user_id, None)
self.interruption_audio_count.pop(user_id, None)
# Cancel existing silence task if any
if user_id in self.silence_tasks and not self.silence_tasks[user_id].done():
self.silence_tasks[user_id].cancel()
# Start new silence detection task
self.silence_tasks[user_id] = asyncio.create_task(
self._detect_silence(user_id)
)
except Exception as e:
logger.error(f"Failed to send audio chunk for user {user_id}: {e}")
async def _detect_silence(self, user_id: int):
"""
Wait for silence timeout and send 'final' command to STT.
This is called after each audio chunk. If no more audio arrives within
the silence_timeout period, we send the 'final' command to get the
complete transcription.
Args:
user_id: Discord user ID
"""
try:
# Wait for silence timeout
await asyncio.sleep(self.silence_timeout)
# Check if we still have an active STT client
stt_client = self.stt_clients.get(user_id)
if not stt_client or not stt_client.is_connected():
return
# Send final command to get complete transcription
logger.debug(f"Silence detected for user {user_id}, requesting final transcript")
await stt_client.send_final()
except asyncio.CancelledError:
# Task was cancelled because new audio arrived
pass
except Exception as e:
logger.error(f"Error in silence detection for user {user_id}: {e}")
async def _on_vad_event(self, user_id: int, event: dict):
"""
Handle VAD event from STT.
Args:
user_id: Discord user ID
event: VAD event dictionary with 'event' and 'probability' keys
"""
user = self.users.get(user_id)
event_type = event.get('event', 'unknown')
probability = event.get('probability', 0.0)
logger.debug(f"VAD [{user.name if user else user_id}]: {event_type} (prob={probability:.3f})")
# Notify voice manager - pass the full event dict
if hasattr(self.voice_manager, 'on_user_vad_event'):
await self.voice_manager.on_user_vad_event(user_id, event)
async def _on_partial_transcript(self, user_id: int, text: str):
"""
Handle partial transcript from STT.
@@ -438,7 +447,6 @@ class VoiceReceiverSink(voice_recv.AudioSink):
"""
user = self.users.get(user_id)
logger.info(f"[VOICE_RECEIVER] Partial [{user.name if user else user_id}]: {text}")
print(f"[DEBUG] PARTIAL TRANSCRIPT RECEIVED: {text}") # Extra debug
# Notify voice manager
if hasattr(self.voice_manager, 'on_partial_transcript'):
@@ -456,29 +464,11 @@ class VoiceReceiverSink(voice_recv.AudioSink):
user: Discord user object
"""
logger.info(f"[VOICE_RECEIVER] Final [{user.name if user else user_id}]: {text}")
print(f"[DEBUG] FINAL TRANSCRIPT RECEIVED: {text}") # Extra debug
# Notify voice manager - THIS TRIGGERS LLM RESPONSE
if hasattr(self.voice_manager, 'on_final_transcript'):
await self.voice_manager.on_final_transcript(user_id, text)
async def _on_interruption(self, user_id: int, probability: float):
"""
Handle interruption detection from STT.
This cancels Miku's current speech if user interrupts.
Args:
user_id: Discord user ID
probability: Interruption confidence probability
"""
user = self.users.get(user_id)
logger.info(f"Interruption from [{user.name if user else user_id}] (prob={probability:.3f})")
# Notify voice manager - THIS CANCELS MIKU'S SPEECH
if hasattr(self.voice_manager, 'on_user_interruption'):
await self.voice_manager.on_user_interruption(user_id, probability)
def get_listening_users(self) -> list:
"""
Get list of users currently being listened to.
@@ -489,30 +479,10 @@ class VoiceReceiverSink(voice_recv.AudioSink):
return [
{
'user_id': user_id,
'username': user.name if user else 'Unknown',
'connected': client.is_connected()
'username': self.users.get(user_id, {}).name if self.users.get(user_id) else 'Unknown',
'connected': self.stt_clients.get(user_id, {}).connected if self.stt_clients.get(user_id) else False
}
for user_id, (user, client) in
[(uid, (self.users.get(uid), self.stt_clients.get(uid)))
for uid in self.stt_clients.keys()]
for user_id in self.stt_clients.keys()
]
@voice_recv.AudioSink.listener()
def on_voice_member_speaking_start(self, member: discord.Member):
"""
Called when a member starts speaking (green circle appears).
This is a virtual event from discord-ext-voice-recv based on packet activity.
"""
if member.id in self.stt_clients:
logger.debug(f"🎤 {member.name} started speaking")
@voice_recv.AudioSink.listener()
def on_voice_member_speaking_stop(self, member: discord.Member):
"""
Called when a member stops speaking (green circle disappears).
This is a virtual event from discord-ext-voice-recv based on packet activity.
"""
if member.id in self.stt_clients:
logger.debug(f"🔇 {member.name} stopped speaking")
# Discord VAD events removed - we rely entirely on RealtimeSTT's VAD for speech detection