Character schema v2: background, dialogue_style, appearance, skills, gaze_presets with automatic v1→v2 migration. LLM-assisted character creation via Character MCP server. Two-tier memory system (personal per-character + general shared) with budget-based injection into LLM system prompt. Per-character TTS voice routing via state file — Wyoming TTS server reads active config to route between Kokoro (local) and ElevenLabs (cloud PCM 24kHz). Dashboard: memories page, conversation history, character profile on cards, auto-TTS engine selection from character config. Also includes VTube Studio expression bridge and ComfyUI API guide. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
171 lines
5.5 KiB
Python
171 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test script for VTube Studio Expression Bridge.
|
|
|
|
Usage:
|
|
python3 test-expressions.py # test all expressions
|
|
python3 test-expressions.py --auth # run auth flow first
|
|
python3 test-expressions.py --lipsync # test lip sync parameter
|
|
python3 test-expressions.py --latency # measure round-trip latency
|
|
|
|
Requires the vtube-bridge to be running on port 8002.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
BRIDGE_URL = "http://localhost:8002"
|
|
|
|
EXPRESSIONS = ["idle", "listening", "thinking", "speaking", "happy", "sad", "surprised", "error"]
|
|
|
|
|
|
def _post(path: str, data: dict | None = None) -> dict:
|
|
body = json.dumps(data or {}).encode()
|
|
req = urllib.request.Request(
|
|
f"{BRIDGE_URL}{path}",
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
return json.loads(resp.read())
|
|
|
|
|
|
def _get(path: str) -> dict:
|
|
req = urllib.request.Request(f"{BRIDGE_URL}{path}")
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
return json.loads(resp.read())
|
|
|
|
|
|
def check_bridge():
|
|
"""Verify bridge is running and connected."""
|
|
try:
|
|
status = _get("/status")
|
|
print(f"Bridge status: connected={status['connected']}, authenticated={status['authenticated']}")
|
|
print(f" Expressions: {', '.join(status.get('expressions', []))}")
|
|
if not status["connected"]:
|
|
print("\n WARNING: Not connected to VTube Studio. Is it running?")
|
|
if not status["authenticated"]:
|
|
print(" WARNING: Not authenticated. Run with --auth to initiate auth flow.")
|
|
return status
|
|
except Exception as e:
|
|
print(f"ERROR: Cannot reach bridge at {BRIDGE_URL}: {e}")
|
|
print(" Is vtube-bridge.py running?")
|
|
sys.exit(1)
|
|
|
|
|
|
def run_auth():
|
|
"""Initiate auth flow — user must click Allow in VTube Studio."""
|
|
print("Requesting authentication token...")
|
|
print(" >>> Click 'Allow' in VTube Studio when prompted <<<")
|
|
result = _post("/auth")
|
|
print(f" Result: {json.dumps(result, indent=2)}")
|
|
return result
|
|
|
|
|
|
def test_expressions(delay: float = 2.0):
|
|
"""Cycle through all expressions with a pause between each."""
|
|
print(f"\nCycling through {len(EXPRESSIONS)} expressions ({delay}s each):\n")
|
|
for expr in EXPRESSIONS:
|
|
print(f" → {expr}...", end=" ", flush=True)
|
|
t0 = time.monotonic()
|
|
result = _post("/expression", {"event": expr})
|
|
dt = (time.monotonic() - t0) * 1000
|
|
if result.get("ok"):
|
|
print(f"OK ({dt:.0f}ms)")
|
|
else:
|
|
print(f"FAILED: {result.get('error', 'unknown')}")
|
|
time.sleep(delay)
|
|
|
|
# Return to idle
|
|
_post("/expression", {"event": "idle"})
|
|
print("\n Returned to idle.")
|
|
|
|
|
|
def test_lipsync(duration: float = 3.0):
|
|
"""Simulate lip sync by sweeping MouthOpen 0→1→0."""
|
|
import math
|
|
print(f"\nTesting lip sync (MouthOpen sweep, {duration}s)...\n")
|
|
|
|
fps = 20
|
|
frames = int(duration * fps)
|
|
for i in range(frames):
|
|
t = i / frames
|
|
# Sine wave for smooth open/close
|
|
value = abs(math.sin(t * math.pi * 4))
|
|
value = round(value, 3)
|
|
_post("/parameter", {"name": "MouthOpen", "value": value})
|
|
print(f"\r MouthOpen = {value:.3f}", end="", flush=True)
|
|
time.sleep(1.0 / fps)
|
|
|
|
_post("/parameter", {"name": "MouthOpen", "value": 0.0})
|
|
print("\r MouthOpen = 0.000 (done) ")
|
|
|
|
|
|
def test_latency(iterations: int = 20):
|
|
"""Measure expression trigger round-trip latency."""
|
|
print(f"\nMeasuring latency ({iterations} iterations)...\n")
|
|
times = []
|
|
for i in range(iterations):
|
|
expr = "thinking" if i % 2 == 0 else "idle"
|
|
t0 = time.monotonic()
|
|
_post("/expression", {"event": expr})
|
|
dt = (time.monotonic() - t0) * 1000
|
|
times.append(dt)
|
|
print(f" {i+1:2d}. {expr:10s} → {dt:.1f}ms")
|
|
|
|
avg = sum(times) / len(times)
|
|
mn = min(times)
|
|
mx = max(times)
|
|
print(f"\n Avg: {avg:.1f}ms Min: {mn:.1f}ms Max: {mx:.1f}ms")
|
|
if avg < 100:
|
|
print(" PASS: Average latency under 100ms target")
|
|
else:
|
|
print(" WARNING: Average latency exceeds 100ms target")
|
|
|
|
# Return to idle
|
|
_post("/expression", {"event": "idle"})
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="VTube Studio Expression Bridge Tester")
|
|
parser.add_argument("--auth", action="store_true", help="Run auth flow")
|
|
parser.add_argument("--lipsync", action="store_true", help="Test lip sync parameter sweep")
|
|
parser.add_argument("--latency", action="store_true", help="Measure round-trip latency")
|
|
parser.add_argument("--delay", type=float, default=2.0, help="Delay between expressions (default: 2s)")
|
|
parser.add_argument("--all", action="store_true", help="Run all tests")
|
|
args = parser.parse_args()
|
|
|
|
print("VTube Studio Expression Bridge Tester")
|
|
print("=" * 42)
|
|
|
|
status = check_bridge()
|
|
|
|
if args.auth:
|
|
run_auth()
|
|
print()
|
|
status = check_bridge()
|
|
|
|
if not status.get("authenticated") and not args.auth:
|
|
print("\nNot authenticated — skipping expression tests.")
|
|
print("Run with --auth to authenticate, or start VTube Studio first.")
|
|
return
|
|
|
|
if args.all:
|
|
test_expressions(args.delay)
|
|
test_lipsync()
|
|
test_latency()
|
|
elif args.lipsync:
|
|
test_lipsync()
|
|
elif args.latency:
|
|
test_latency()
|
|
else:
|
|
test_expressions(args.delay)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|