feat: character system v2 — schema upgrade, memory system, per-character TTS routing
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>
This commit is contained in:
170
homeai-visual/scripts/test-expressions.py
Normal file
170
homeai-visual/scripts/test-expressions.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user