Steve Nguyen commited on
Commit
b52ebca
·
0 Parent(s):
Files changed (7) hide show
  1. README.md +83 -0
  2. dynamixel.py +119 -0
  3. engine.py +364 -0
  4. main.py +1448 -0
  5. pyproject.toml +11 -0
  6. story.py +238 -0
  7. web/dxl_webserial.js +187 -0
README.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Visual Novel Demo
2
+
3
+ Run `python main.py` (or `uv run python main.py` if you prefer `uv`) to launch a Gradio-powered visual novel sandbox.
4
+
5
+ ### Features
6
+ - Register characters with sprite URLs or inline SVG data-URIs (see `create_sprite_data_url`).
7
+ - Toggle simple idle animation per character (set `animated=True`) or point to GIF/WebP assets for full animation.
8
+ - Change backgrounds between scenes.
9
+ - Show, hide, and move characters between left/center/right anchors.
10
+ - Display narration or speaker dialogue in a speech-bubble overlay anchored to the scene.
11
+ - Navigate forward/backward through the story timeline.
12
+ - Opt-in webcam overlay per scene: call `builder.set_camera(True|False)` to show or hide the FastRTC stream alongside your story.
13
+ - Voice sandbox: record or upload microphone audio, forward it to a placeholder AI companion, and hear a synthetic confirmation tone back.
14
+ - Dynamixel control (Web Serial): connect over serial to XL330 servos and send goal positions directly from the browser—no Python SDK needed.
15
+ - **Reachy Mini robot control (WebSocket)**: connect to a Reachy Mini robot server and send real-time pose commands for head position, orientation, body yaw, and antennas.
16
+ - Per-scene toggles: show/hide camera, voice, motor, and robot controls with `set_camera`, `set_voice`, `set_motors`, and `set_robot`.
17
+
18
+ #### Customizing sprites
19
+ - Replace the SVG data-URIs in `build_sample_story()` with your own URLs (PNG/GIF/WebP).
20
+ - For animated sprites, provide an animated GIF/WebP URL and set `animated=True` to also enable the floaty idle motion.
21
+ - If you need frame-based animation control, extend `CharacterDefinition` with additional fields (e.g., `animation_frames`) and update `render_scene()` accordingly.
22
+
23
+ #### Camera widget
24
+ - Grant permission when prompted; the browser's default camera is streamed with FastRTC (`WebRTC` component).
25
+ - Scenes control whether the webcam appears. If a scene doesn't request it, you'll see a friendly notice instead of the stream.
26
+ - Browsers typically require HTTPS (or `http://localhost`) plus user permission before the stream can start; if the feed doesn’t appear, reload after granting access.
27
+
28
+ #### Voice sandbox
29
+ - Scenes decide whether voice capture shows up. Call `builder.set_voice(True|False)` per scene; when disabled, the audio UI hides completely.
30
+ - Use the **Voice & Audio Agent** accordion (when visible) to record or upload a clip; hit **Send to voice agent** to hand it to the (placeholder) AI hook.
31
+ - The app echoes your recording for playback and emits a synthetic tone to represent an AI voice. Replace `process_voice_interaction()` in `main.py` with real ASR/LLM/TTS calls to integrate your model stack.
32
+ - Default prompt text gives the agent scene context; edit it freely.
33
+
34
+ #### Dynamixel XL330 control
35
+ - The control panel lives entirely in the browser using the Web Serial API (Chrome/Edge on desktop). When prompted, select the USB/serial adapter attached to your Dynamixel bus.
36
+ - Choose baud, motor ID, and goal angle in the **Dynamixel XL330 Control** panel; click **Connect serial** (triggers the browser port picker) then **Send goal**. Use **Torque on/off** to toggle torque.
37
+ - Commands write Protocol 2.0 registers: torque enable (64) and goal position (116, 4 bytes). Angles 0–360° map to 0–4095 ticks.
38
+ - Frontend code lives in `web/dxl_webserial.js` and is loaded via `file=web/dxl_webserial.js`, mirroring the structure of `feetech.js`.
39
+
40
+ #### Reachy Mini robot control
41
+ - Connect to a Reachy Mini robot via WebSocket for real-time pose control during story scenes.
42
+ - **Requirements**: A running Reachy Mini server at `localhost:8000` with WebSocket endpoint `/api/move/ws/set_target`.
43
+ - The connection status is shown in the robot control panel with a color-coded indicator (🔴 disconnected / 🟢 connected).
44
+ - **Enable in scenes**: Call `builder.set_robot(True)` to show the robot control widget for specific scenes.
45
+ - **Send poses from story**: Use `builder.send_robot_pose()` to command the robot when a scene is displayed:
46
+ ```python
47
+ builder.send_robot_pose(
48
+ head_x=0.0, head_y=0.0, head_z=0.02, # Head position in meters
49
+ head_roll=0.0, head_pitch=-0.1, head_yaw=0.0, # Head orientation in radians
50
+ body_yaw=0.0, # Body rotation in radians
51
+ antenna_left=-0.2, antenna_right=0.2 # Antenna positions in radians
52
+ )
53
+ ```
54
+ - WebSocket automatically connects when the widget becomes visible and reconnects if disconnected.
55
+ - Poses are sent automatically when navigating to scenes with robot commands (similar to motor commands and audio).
56
+
57
+ Edit `main.py` to customize `build_sample_story()` or create your own builder logic with `VisualNovelBuilder`.
58
+
59
+ ### Using Custom Assets
60
+
61
+ Place your files in the `assets/` directory:
62
+ - `assets/backgrounds/` - Background images (1200x800 recommended)
63
+ - `assets/sprites/` - Character sprites (400x800 recommended, PNG with transparency)
64
+ - `assets/audio/` - Audio files (WAV, MP3, etc.)
65
+
66
+ Then use the helper functions in your story:
67
+
68
+ ```python
69
+ from engine import background_asset, sprite_asset, audio_asset
70
+
71
+ builder.set_background(background_asset("my_background.png"), label="My Scene")
72
+
73
+ builder.set_characters([
74
+ CharacterDefinition(
75
+ name="Hero",
76
+ image_url=sprite_asset("hero.png"),
77
+ animated=False
78
+ ),
79
+ ])
80
+
81
+ # Play audio when scene is displayed
82
+ builder.play_sound(audio_asset("my_sound.wav"))
83
+ ```
dynamixel.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dynamixel Protocol 2.0 implementation in Python."""
2
+
3
+ from typing import List, Tuple
4
+
5
+
6
+ def crc16_update(crc_accum: int, data: bytes) -> int:
7
+ """Update CRC16 with new data."""
8
+ crc_table = [
9
+ 0x0000, 0x8005, 0x800F, 0x000A, 0x801B, 0x001E, 0x0014, 0x8011,
10
+ 0x8033, 0x0036, 0x003C, 0x8039, 0x0028, 0x802D, 0x8027, 0x0022,
11
+ 0x8063, 0x0066, 0x006C, 0x8069, 0x0078, 0x807D, 0x8077, 0x0072,
12
+ 0x0050, 0x8055, 0x805F, 0x005A, 0x804B, 0x004E, 0x0044, 0x8041,
13
+ 0x80C3, 0x00C6, 0x00CC, 0x80C9, 0x00D8, 0x80DD, 0x80D7, 0x00D2,
14
+ 0x00F0, 0x80F5, 0x80FF, 0x00FA, 0x80EB, 0x00EE, 0x00E4, 0x80E1,
15
+ 0x00A0, 0x80A5, 0x80AF, 0x00AA, 0x80BB, 0x00BE, 0x00B4, 0x80B1,
16
+ 0x8093, 0x0096, 0x009C, 0x8099, 0x0088, 0x808D, 0x8087, 0x0082,
17
+ 0x8183, 0x0186, 0x018C, 0x8189, 0x0198, 0x819D, 0x8197, 0x0192,
18
+ 0x01B0, 0x81B5, 0x81BF, 0x01BA, 0x81AB, 0x01AE, 0x01A4, 0x81A1,
19
+ 0x01E0, 0x81E5, 0x81EF, 0x01EA, 0x81FB, 0x01FE, 0x01F4, 0x81F1,
20
+ 0x81D3, 0x01D6, 0x01DC, 0x81D9, 0x01C8, 0x81CD, 0x81C7, 0x01C2,
21
+ 0x0140, 0x8145, 0x814F, 0x014A, 0x815B, 0x015E, 0x0154, 0x8151,
22
+ 0x8173, 0x0176, 0x017C, 0x8179, 0x0168, 0x816D, 0x8167, 0x0162,
23
+ 0x8123, 0x0126, 0x012C, 0x8129, 0x0138, 0x813D, 0x8137, 0x0132,
24
+ 0x0110, 0x8115, 0x811F, 0x011A, 0x810B, 0x010E, 0x0104, 0x8101,
25
+ 0x8303, 0x0306, 0x030C, 0x8309, 0x0318, 0x831D, 0x8317, 0x0312,
26
+ 0x0330, 0x8335, 0x833F, 0x033A, 0x832B, 0x032E, 0x0324, 0x8321,
27
+ 0x0360, 0x8365, 0x836F, 0x036A, 0x837B, 0x037E, 0x0374, 0x8371,
28
+ 0x8353, 0x0356, 0x035C, 0x8359, 0x0348, 0x834D, 0x8347, 0x0342,
29
+ 0x03C0, 0x83C5, 0x83CF, 0x03CA, 0x83DB, 0x03DE, 0x03D4, 0x83D1,
30
+ 0x83F3, 0x03F6, 0x03FC, 0x83F9, 0x03E8, 0x83ED, 0x83E7, 0x03E2,
31
+ 0x83A3, 0x03A6, 0x03AC, 0x83A9, 0x03B8, 0x83BD, 0x83B7, 0x03B2,
32
+ 0x0390, 0x8395, 0x839F, 0x039A, 0x838B, 0x038E, 0x0384, 0x8381,
33
+ 0x0280, 0x8285, 0x828F, 0x028A, 0x829B, 0x029E, 0x0294, 0x8291,
34
+ 0x82B3, 0x02B6, 0x02BC, 0x82B9, 0x02A8, 0x82AD, 0x82A7, 0x02A2,
35
+ 0x82E3, 0x02E6, 0x02EC, 0x82E9, 0x02F8, 0x82FD, 0x82F7, 0x02F2,
36
+ 0x02D0, 0x82D5, 0x82DF, 0x02DA, 0x82CB, 0x02CE, 0x02C4, 0x82C1,
37
+ 0x8243, 0x0246, 0x024C, 0x8249, 0x0258, 0x825D, 0x8257, 0x0252,
38
+ 0x0270, 0x8275, 0x827F, 0x027A, 0x826B, 0x026E, 0x0264, 0x8261,
39
+ 0x0220, 0x8225, 0x822F, 0x022A, 0x823B, 0x023E, 0x0234, 0x8231,
40
+ 0x8213, 0x0216, 0x021C, 0x8219, 0x0208, 0x820D, 0x8207, 0x0202
41
+ ]
42
+
43
+ for byte in data:
44
+ i = ((crc_accum >> 8) ^ byte) & 0xFF
45
+ crc_accum = ((crc_accum << 8) ^ crc_table[i]) & 0xFFFF
46
+
47
+ return crc_accum
48
+
49
+
50
+ def build_packet(motor_id: int, instruction: int, params: List[int]) -> bytes:
51
+ """Build a Dynamixel Protocol 2.0 packet."""
52
+ # Header
53
+ packet = bytearray([0xFF, 0xFF, 0xFD, 0x00])
54
+
55
+ # ID
56
+ packet.append(motor_id)
57
+
58
+ # Length (instruction + params + CRC)
59
+ length = len(params) + 3
60
+ packet.append(length & 0xFF)
61
+ packet.append((length >> 8) & 0xFF)
62
+
63
+ # Instruction
64
+ packet.append(instruction)
65
+
66
+ # Parameters
67
+ packet.extend(params)
68
+
69
+ # CRC
70
+ crc = crc16_update(0, bytes(packet))
71
+ packet.append(crc & 0xFF)
72
+ packet.append((crc >> 8) & 0xFF)
73
+
74
+ return bytes(packet)
75
+
76
+
77
+ def ping_packet(motor_id: int) -> bytes:
78
+ """Create a ping packet."""
79
+ return build_packet(motor_id, 0x01, [])
80
+
81
+
82
+ def torque_enable_packet(motor_id: int, enable: bool) -> bytes:
83
+ """Create a torque enable/disable packet."""
84
+ # Write instruction: Address (2 bytes) + Data
85
+ # Address 64 (Torque Enable), 1 byte data
86
+ return build_packet(motor_id, 0x03, [64, 0, 1 if enable else 0])
87
+
88
+
89
+ def goal_position_packet(motor_id: int, position: int) -> bytes:
90
+ """Create a goal position packet."""
91
+ # Write instruction: Address (2 bytes) + Data (4 bytes)
92
+ # Address 116 (Goal Position)
93
+ return build_packet(motor_id, 0x03, [
94
+ 116, 0, # Address (low byte, high byte)
95
+ position & 0xFF,
96
+ (position >> 8) & 0xFF,
97
+ (position >> 16) & 0xFF,
98
+ (position >> 24) & 0xFF
99
+ ])
100
+
101
+
102
+ def parse_status_packet(data: bytes) -> Tuple[bool, str]:
103
+ """Parse a status packet and return (success, message)."""
104
+ if len(data) < 11:
105
+ return False, f"Packet too short: {len(data)} bytes"
106
+
107
+ # Check header
108
+ if data[0] != 0xFF or data[1] != 0xFF or data[2] != 0xFD or data[3] != 0x00:
109
+ return False, "Invalid header"
110
+
111
+ motor_id = data[4]
112
+ length = data[5] | (data[6] << 8)
113
+ instruction = data[7]
114
+ error = data[8]
115
+
116
+ if error != 0:
117
+ return False, f"Motor error: {error:#04x}"
118
+
119
+ return True, f"OK (ID: {motor_id})"
engine.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visual Novel Engine - Core classes and builder for creating interactive stories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from typing import Dict, List, Optional
9
+
10
+ DEFAULT_BACKGROUND = "https://images.unsplash.com/photo-1506744038136-46273834b3fb?auto=format&fit=crop&w=1200&q=80"
11
+ POSITION_OFFSETS = {
12
+ "left": "20%",
13
+ "center": "50%",
14
+ "right": "80%",
15
+ }
16
+
17
+
18
+ # Asset helper functions
19
+ def background_asset(filename: str) -> str:
20
+ """Get the URL to a background image in the assets directory."""
21
+ return os.path.join("user-assets", "backgrounds", filename)
22
+
23
+
24
+ def sprite_asset(filename: str) -> str:
25
+ """Get the URL to a sprite image in the assets directory."""
26
+ return os.path.join("user-assets", "sprites", filename)
27
+
28
+
29
+ def audio_asset(filename: str) -> str:
30
+ """Get the URL to an audio file in the assets directory."""
31
+ return os.path.join("user-assets", "audio", filename)
32
+
33
+
34
+ def create_sprite_data_url(bg_color: str = "#fef3c7", border_color: str = "#ea580c") -> str:
35
+ """Create a simple inline SVG data-URI for a character sprite."""
36
+ svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="200" height="400" viewBox="0 0 200 400">
37
+ <rect width="200" height="400" fill="{bg_color}" rx="20"/>
38
+ <circle cx="100" cy="120" r="50" fill="{border_color}" opacity="0.6"/>
39
+ <rect x="60" y="180" width="80" height="140" fill="{border_color}" opacity="0.4" rx="10"/>
40
+ </svg>"""
41
+ encoded = svg.replace('"', '%22').replace('#', '%23').replace('<', '%3C').replace('>', '%3E')
42
+ return f"data:image/svg+xml,{encoded}"
43
+
44
+
45
+ @dataclass
46
+ class CharacterDefinition:
47
+ name: str
48
+ image_url: str
49
+ animated: bool = False
50
+
51
+
52
+ @dataclass
53
+ class CharacterSprite:
54
+ name: str
55
+ image_url: str
56
+ position: str = "center"
57
+ visible: bool = False
58
+ animation: str = "" # Animation type: "", "idle", "shake", "bounce", "pulse"
59
+ scale: float = 1.0 # Scale multiplier (1.0 = 100%, 0.5 = 50%, 2.0 = 200%)
60
+
61
+
62
+ @dataclass
63
+ class Choice:
64
+ text: str
65
+ next_scene_index: int
66
+
67
+
68
+ @dataclass
69
+ class InputRequest:
70
+ prompt: str
71
+ variable_name: str
72
+
73
+
74
+ @dataclass
75
+ class MotorCommand:
76
+ motor_id: int
77
+ position: int # Position in degrees (0-360)
78
+
79
+
80
+ @dataclass
81
+ class RobotPose:
82
+ """Robot pose command for Reachy Mini control."""
83
+ head_x: float = 0.0 # meters
84
+ head_y: float = 0.0 # meters
85
+ head_z: float = 0.0 # meters
86
+ head_roll: float = 0.0 # radians
87
+ head_pitch: float = 0.0 # radians
88
+ head_yaw: float = 0.0 # radians
89
+ body_yaw: float = 0.0 # radians
90
+ antenna_left: float = 0.0 # radians
91
+ antenna_right: float = 0.0 # radians
92
+
93
+
94
+ @dataclass
95
+ class SceneState:
96
+ background_url: str
97
+ background_label: str
98
+ characters: Dict[str, CharacterSprite]
99
+ speaker: str
100
+ text: str
101
+ note: str
102
+ show_camera: bool = False
103
+ show_voice: bool = False
104
+ show_motors: bool = False
105
+ show_robot: bool = False # Show robot control widget
106
+ background_blur: int = 0 # Blur amount in pixels (0 = no blur, 5-10 = good range)
107
+ stage_url: str = "" # Stage image on top of background, below characters
108
+ stage_blur: int = 0 # Blur amount for stage layer
109
+ choices: Optional[List[Choice]] = None
110
+ input_request: Optional[InputRequest] = None
111
+ path: Optional[str] = None # Which story branch this scene belongs to
112
+ motor_commands: List[MotorCommand] = field(default_factory=list) # Commands to execute on scene entry
113
+ audio_file: Optional[str] = None # Audio file to play when scene is displayed
114
+ robot_pose: Optional[RobotPose] = None # Robot pose to send when scene is displayed
115
+
116
+
117
+ class VisualNovelBuilder:
118
+ """Builder to construct a linear or branching visual novel scene-by-scene."""
119
+
120
+ def __init__(self) -> None:
121
+ self._states: List[SceneState] = []
122
+ self._character_defs: Dict[str, CharacterDefinition] = {}
123
+ self._current_background: str = DEFAULT_BACKGROUND
124
+ self._current_label: str = ""
125
+ self._current_sprites: Dict[str, CharacterSprite] = {}
126
+ self._current_show_camera: bool = False
127
+ self._current_show_voice: bool = False
128
+ self._current_show_motors: bool = False
129
+ self._current_show_robot: bool = False
130
+ self._current_background_blur: int = 0
131
+ self._current_stage: str = ""
132
+ self._current_stage_blur: int = 0
133
+ self._current_path: Optional[str] = None
134
+
135
+ def set_characters(self, characters: List[CharacterDefinition]) -> None:
136
+ """Register character definitions (name, image_url, animated)."""
137
+ for char in characters:
138
+ self._character_defs[char.name] = char
139
+ self._current_sprites[char.name] = CharacterSprite(
140
+ name=char.name,
141
+ image_url=char.image_url,
142
+ position="center",
143
+ visible=False,
144
+ animation="idle" if char.animated else "",
145
+ )
146
+
147
+ def set_background(self, image_url: str, label: str = "") -> None:
148
+ """Change the background image and optionally set a label."""
149
+ state = self._clone_state()
150
+ state.background_url = image_url
151
+ state.background_label = label
152
+ state.note = f"Background: {label or 'custom'}"
153
+ self._push_state(state)
154
+
155
+ def set_camera(self, show: bool) -> None:
156
+ """Toggle the camera display for the next scene."""
157
+ self._current_show_camera = show
158
+
159
+ def set_voice(self, show: bool) -> None:
160
+ """Toggle the voice capture UI for the next scene."""
161
+ self._current_show_voice = show
162
+
163
+ def set_motors(self, show: bool) -> None:
164
+ """Toggle the motor control UI for the next scene."""
165
+ self._current_show_motors = show
166
+
167
+ def set_robot(self, show: bool) -> None:
168
+ """Toggle the robot control UI for the next scene."""
169
+ self._current_show_robot = show
170
+
171
+ def set_background_blur(self, blur_amount: int) -> None:
172
+ """Set the background blur amount in pixels (0 = no blur, 5-10 is typical range)."""
173
+ self._current_background_blur = blur_amount
174
+
175
+ def set_stage(self, image_url: str) -> None:
176
+ """Set the stage image (layer between background and characters)."""
177
+ self._current_stage = image_url
178
+
179
+ def set_stage_blur(self, blur_amount: int) -> None:
180
+ """Set the stage blur amount in pixels (0 = no blur, 5-10 is typical range)."""
181
+ self._current_stage_blur = blur_amount
182
+
183
+ def set_path(self, path: Optional[str]) -> None:
184
+ """Set the story path for subsequent scenes."""
185
+ self._current_path = path
186
+
187
+ def show_character(self, name: str, position: str = "center") -> None:
188
+ """Display a character at a specific position."""
189
+ state = self._clone_state()
190
+ if name in state.characters:
191
+ state.characters[name].visible = True
192
+ state.characters[name].position = position
193
+ state.note = f"Show {name} at {position}"
194
+ self._push_state(state)
195
+
196
+ def hide_character(self, name: str) -> None:
197
+ """Hide a character from the scene."""
198
+ state = self._clone_state()
199
+ if name in state.characters:
200
+ state.characters[name].visible = False
201
+ state.note = f"Hide {name}"
202
+ self._push_state(state)
203
+
204
+ def move_character(self, name: str, position: str) -> None:
205
+ """Move a character to a new position."""
206
+ state = self._clone_state()
207
+ if name in state.characters:
208
+ state.characters[name].position = position
209
+ state.note = f"Move {name} to {position}"
210
+ self._push_state(state)
211
+
212
+ def change_character_sprite(self, name: str, image_url: str) -> None:
213
+ """Change a character's sprite image (e.g., for different emotions)."""
214
+ state = self._clone_state()
215
+ if name in state.characters:
216
+ state.characters[name].image_url = image_url
217
+ state.note = f"Change {name} sprite"
218
+ self._push_state(state)
219
+
220
+ def set_character_animation(self, name: str, animation: str) -> None:
221
+ """Set character animation. Options: '', 'idle', 'shake', 'bounce', 'pulse'."""
222
+ state = self._clone_state()
223
+ if name in state.characters:
224
+ state.characters[name].animation = animation
225
+ state.note = f"{name} animation: {animation or 'none'}"
226
+ self._push_state(state)
227
+
228
+ def set_character_scale(self, name: str, scale: float) -> None:
229
+ """Set character scale. 1.0 = 100%, 0.5 = 50%, 2.0 = 200%."""
230
+ state = self._clone_state()
231
+ if name in state.characters:
232
+ state.characters[name].scale = scale
233
+ state.note = f"{name} scale: {scale}"
234
+ self._push_state(state)
235
+
236
+ def dialogue(self, speaker: str, text: str) -> None:
237
+ """Add a dialogue line."""
238
+ state = self._clone_state()
239
+ state.speaker = speaker
240
+ state.text = text
241
+ state.note = f"{speaker}: {text[:30]}..."
242
+ self._push_state(state)
243
+
244
+ def narration(self, text: str) -> None:
245
+ """Add narration (no speaker)."""
246
+ state = self._clone_state()
247
+ state.speaker = ""
248
+ state.text = text
249
+ state.note = f"Narration: {text[:30]}..."
250
+ self._push_state(state)
251
+
252
+ def request_input(self, prompt: str, variable_name: str) -> None:
253
+ """Request text input from the user."""
254
+ state = self._clone_state()
255
+ state.input_request = InputRequest(prompt=prompt, variable_name=variable_name)
256
+ state.note = f"Input: {variable_name}"
257
+ self._push_state(state)
258
+
259
+ def send_motor_command(self, motor_id: int, position: int) -> None:
260
+ """Send a motor command when this scene is displayed."""
261
+ state = self._clone_state()
262
+ state.motor_commands.append(MotorCommand(motor_id=motor_id, position=position))
263
+ state.note = f"Motor {motor_id} → {position}°"
264
+ self._push_state(state)
265
+
266
+ def send_motor_commands(self, commands: List[tuple[int, int]]) -> None:
267
+ """Send multiple motor commands when this scene is displayed.
268
+
269
+ Args:
270
+ commands: List of (motor_id, position) tuples
271
+ """
272
+ state = self._clone_state()
273
+ for motor_id, position in commands:
274
+ state.motor_commands.append(MotorCommand(motor_id=motor_id, position=position))
275
+ state.note = f"Motors: {len(commands)} commands"
276
+ self._push_state(state)
277
+
278
+ def send_robot_pose(
279
+ self,
280
+ head_x: float = 0.0,
281
+ head_y: float = 0.0,
282
+ head_z: float = 0.0,
283
+ head_roll: float = 0.0,
284
+ head_pitch: float = 0.0,
285
+ head_yaw: float = 0.0,
286
+ body_yaw: float = 0.0,
287
+ antenna_left: float = 0.0,
288
+ antenna_right: float = 0.0,
289
+ ) -> None:
290
+ """Send a robot pose command when this scene is displayed.
291
+
292
+ Args:
293
+ head_x: X position in meters
294
+ head_y: Y position in meters
295
+ head_z: Z position in meters
296
+ head_roll: Roll angle in radians
297
+ head_pitch: Pitch angle in radians
298
+ head_yaw: Yaw angle in radians
299
+ body_yaw: Body yaw angle in radians
300
+ antenna_left: Left antenna angle in radians
301
+ antenna_right: Right antenna angle in radians
302
+ """
303
+ state = self._clone_state()
304
+ state.robot_pose = RobotPose(
305
+ head_x=head_x,
306
+ head_y=head_y,
307
+ head_z=head_z,
308
+ head_roll=head_roll,
309
+ head_pitch=head_pitch,
310
+ head_yaw=head_yaw,
311
+ body_yaw=body_yaw,
312
+ antenna_left=antenna_left,
313
+ antenna_right=antenna_right,
314
+ )
315
+ state.note = "Robot pose command"
316
+ self._push_state(state)
317
+
318
+ def play_sound(self, audio_file: str) -> None:
319
+ """Play an audio file when this scene is displayed.
320
+
321
+ Args:
322
+ audio_file: Path to audio file (relative to assets/audio/ or absolute path)
323
+ """
324
+ state = self._clone_state()
325
+ state.audio_file = audio_file
326
+ state.note = f"Audio: {audio_file}"
327
+ self._push_state(state)
328
+
329
+ def add_choice(self, text: str, next_scene_index: int) -> None:
330
+ """Add a choice to the current scene (for branching)."""
331
+ if self._states:
332
+ if self._states[-1].choices is None:
333
+ self._states[-1].choices = []
334
+ self._states[-1].choices.append(Choice(text=text, next_scene_index=next_scene_index))
335
+
336
+ def _clone_state(self) -> SceneState:
337
+ """Clone the current state for the next scene."""
338
+ return SceneState(
339
+ background_url=self._current_background,
340
+ background_label=self._current_label,
341
+ characters=copy.deepcopy(self._current_sprites),
342
+ speaker="",
343
+ text="",
344
+ note="",
345
+ show_camera=self._current_show_camera,
346
+ show_voice=self._current_show_voice,
347
+ show_motors=self._current_show_motors,
348
+ show_robot=self._current_show_robot,
349
+ background_blur=self._current_background_blur,
350
+ stage_url=self._current_stage,
351
+ stage_blur=self._current_stage_blur,
352
+ path=self._current_path,
353
+ )
354
+
355
+ def _push_state(self, state: SceneState) -> None:
356
+ """Push a new state and update internal tracking."""
357
+ self._states.append(state)
358
+ self._current_background = state.background_url
359
+ self._current_label = state.background_label
360
+ self._current_sprites = copy.deepcopy(state.characters)
361
+
362
+ def build(self) -> List[SceneState]:
363
+ """Return the finalized list of scene states."""
364
+ return self._states
main.py ADDED
@@ -0,0 +1,1448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visual Novel Gradio App - Main application with UI and handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import urllib.parse
7
+ import numpy as np
8
+ from typing import Optional
9
+
10
+ import gradio as gr
11
+ from fastrtc import WebRTC
12
+ from fastapi import FastAPI
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ from engine import SceneState, POSITION_OFFSETS, Choice, InputRequest
16
+ from story import build_sample_story
17
+
18
+
19
+ def passthrough_stream(frame):
20
+ """Return the incoming frame untouched so the user sees their feed."""
21
+ return frame
22
+
23
+
24
+ def camera_hint_text(show_camera: bool) -> str:
25
+ if show_camera:
26
+ return "🎥 Webcam overlay is active for this scene."
27
+ return "🕹️ Webcam is hidden for this scene."
28
+
29
+
30
+ def voice_hint_text(show_voice: bool) -> str:
31
+ if show_voice:
32
+ return "🎤 Voice capture is available in this scene."
33
+ return "🔇 Voice capture is hidden for this scene."
34
+
35
+
36
+ def motor_hint_text(show_motors: bool) -> str:
37
+ if show_motors:
38
+ return "🤖 Motor control is available in this scene."
39
+ return "🛑 Motor control hidden for this scene."
40
+
41
+
42
+ def robot_hint_text(show_robot: bool) -> str:
43
+ if show_robot:
44
+ return "🤖 Robot control is available in this scene."
45
+ return "🔒 Robot control hidden for this scene."
46
+
47
+
48
+ # Dynamixel control functions using Python protocol implementation
49
+ def dxl_build_ping_packet(motor_id: int) -> list[int]:
50
+ """Build a ping packet and return as list of bytes."""
51
+ import dynamixel
52
+ packet = dynamixel.ping_packet(motor_id)
53
+ return list(packet)
54
+
55
+
56
+ def dxl_build_torque_packet(motor_id: int, enable: bool) -> list[int]:
57
+ """Build a torque enable/disable packet and return as list of bytes."""
58
+ import dynamixel
59
+ packet = dynamixel.torque_enable_packet(motor_id, enable)
60
+ return list(packet)
61
+
62
+
63
+ def dxl_build_goal_position_packet(motor_id: int, degrees: float) -> list[int]:
64
+ """Build a goal position packet and return as list of bytes."""
65
+ import dynamixel
66
+ # Convert degrees to ticks (0-360° -> 0-4095)
67
+ clamped_deg = max(0.0, min(360.0, degrees))
68
+ ticks = int((clamped_deg / 360.0) * 4095)
69
+ packet = dynamixel.goal_position_packet(motor_id, ticks)
70
+ return list(packet)
71
+
72
+
73
+ def dxl_parse_response(response_bytes: list[int]) -> str:
74
+ """Parse a status packet response and return human-readable result."""
75
+ import dynamixel
76
+ if not response_bytes:
77
+ return "❌ No response received"
78
+ success, message = dynamixel.parse_status_packet(bytes(response_bytes))
79
+ if success:
80
+ return f"✅ {message}"
81
+ else:
82
+ return f"❌ {message}"
83
+
84
+
85
+ def get_scene_motor_packets(story_state: dict) -> list:
86
+ """Extract motor commands from current scene and build packets."""
87
+ scenes = story_state["scenes"]
88
+ current_index = story_state["index"]
89
+ if 0 <= current_index < len(scenes):
90
+ scene = scenes[current_index]
91
+ # Build packet for each motor command
92
+ packets = []
93
+ for cmd in scene.motor_commands:
94
+ packet = dxl_build_goal_position_packet(cmd.motor_id, cmd.position)
95
+ packets.append(packet)
96
+ return packets
97
+ return []
98
+
99
+
100
+ def get_scene_audio(story_state: dict) -> Optional[str]:
101
+ """Extract audio file from current scene."""
102
+ scenes = story_state["scenes"]
103
+ current_index = story_state["index"]
104
+ if 0 <= current_index < len(scenes):
105
+ scene = scenes[current_index]
106
+ return scene.audio_file
107
+ return None
108
+
109
+
110
+ def get_scene_robot_pose(story_state: dict) -> Optional[dict]:
111
+ """Extract robot pose from current scene."""
112
+ scenes = story_state["scenes"]
113
+ current_index = story_state["index"]
114
+ if 0 <= current_index < len(scenes):
115
+ scene = scenes[current_index]
116
+ if scene.robot_pose:
117
+ return {
118
+ "target_head_pose": {
119
+ "x": scene.robot_pose.head_x,
120
+ "y": scene.robot_pose.head_y,
121
+ "z": scene.robot_pose.head_z,
122
+ "roll": scene.robot_pose.head_roll,
123
+ "pitch": scene.robot_pose.head_pitch,
124
+ "yaw": scene.robot_pose.head_yaw,
125
+ },
126
+ "target_body_yaw": scene.robot_pose.body_yaw,
127
+ "target_antennas": [scene.robot_pose.antenna_left, scene.robot_pose.antenna_right],
128
+ }
129
+ return None
130
+
131
+
132
+ def synthesize_tone(sample_rate: int = 16000, duration: float = 1.25) -> tuple[int, np.ndarray]:
133
+ """Generate a short confirmation tone to play back as the AI voice."""
134
+ samples = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
135
+ carrier = np.sin(2 * np.pi * 520 * samples) + 0.4 * np.sin(2 * np.pi * 880 * samples)
136
+ fade_len = int(sample_rate * 0.08)
137
+ envelope = np.ones_like(carrier)
138
+ envelope[:fade_len] *= np.linspace(0.0, 1.0, fade_len)
139
+ envelope[-fade_len:] *= np.linspace(1.0, 0.0, fade_len)
140
+ tone = 0.18 * carrier * envelope
141
+ return sample_rate, tone.astype(np.float32)
142
+
143
+
144
+ def describe_audio_clip(audio: Optional[tuple[int, np.ndarray]]) -> str:
145
+ if audio is None:
146
+ return "No audio captured yet. Hit record to speak with the companion."
147
+ sample_rate, samples = audio
148
+ num_samples = len(samples) if samples is not None else 0
149
+ if num_samples == 0:
150
+ return "Audio appears empty. Please re-record."
151
+ duration = num_samples / float(sample_rate or 1)
152
+ rms = float(np.sqrt(np.mean(np.square(samples))))
153
+ return f"Captured {duration:.2f}s of audio (RMS ~{rms:.3f}). Ready for the AI."
154
+
155
+
156
+ def process_voice_interaction(
157
+ audio: Optional[tuple[int, np.ndarray]], prompt: str
158
+ ) -> tuple[str, Optional[tuple[int, np.ndarray]], str, tuple[int, np.ndarray]]:
159
+ summary = describe_audio_clip(audio)
160
+ user_prompt = (prompt or "React to the current scene.").strip()
161
+ if audio is None:
162
+ ai_line = (
163
+ "AI response pending: record or upload an audio clip so the agent can react."
164
+ )
165
+ response_audio = synthesize_tone()
166
+ return summary, None, ai_line, response_audio
167
+ ai_line = (
168
+ "Imaginary AI companion: I'm using your latest microphone input "
169
+ f"and the prompt \"{user_prompt}\" to craft a response."
170
+ )
171
+ response_audio = synthesize_tone()
172
+ return summary, audio, ai_line, response_audio
173
+
174
+
175
+ def render_scene(
176
+ scene: SceneState, index: int, total: int, variables: dict
177
+ ) -> tuple[str, str, str, bool, bool, bool, bool, Optional[List[Choice]], Optional[InputRequest]]:
178
+ """Generate the HTML stage, dialogue text, and metadata."""
179
+ char_layers = []
180
+ for sprite in scene.characters.values():
181
+ if not sprite.visible:
182
+ continue
183
+ offset = POSITION_OFFSETS.get(sprite.position, "50%")
184
+ # Build class names with animation
185
+ class_names = "character"
186
+ if sprite.animation:
187
+ class_names += f" anim-{sprite.animation}"
188
+ # Apply scale using CSS variable (so animations can use it)
189
+ char_layers.append(
190
+ f"""
191
+ <div class="{class_names}" style="
192
+ left:{offset};
193
+ background-image:url('{sprite.image_url}');
194
+ --char-scale:{sprite.scale};
195
+ " title="{sprite.name}"></div>
196
+ """
197
+ )
198
+ dialogue_markdown = (
199
+ "" if scene.text else ""
200
+ ) # Avoid duplicating the speech bubble content below the stage.
201
+ metadata = f"{scene.background_label or 'Scene'} · {index + 1} / {total}"
202
+ bubble_html = ""
203
+ text_content = (scene.text or "").strip()
204
+
205
+ # Substitute variables in text (e.g., {player_name})
206
+ for var_name, var_value in variables.items():
207
+ text_content = text_content.replace(f"{{{var_name}}}", str(var_value))
208
+
209
+ if text_content:
210
+ speaker_html = (
211
+ f'<div class="bubble-speaker">{scene.speaker}</div>'
212
+ if scene.speaker
213
+ else ""
214
+ )
215
+ bubble_html = f"""
216
+ <div class="speech-bubble">
217
+ {speaker_html}
218
+ <div class="bubble-text">{text_content}</div>
219
+ </div>
220
+ """
221
+ # Apply blur filters to background and stage
222
+ bg_blur_style = f"filter: blur({scene.background_blur}px);" if scene.background_blur > 0 else ""
223
+ stage_blur_style = f"filter: blur({scene.stage_blur}px);" if scene.stage_blur > 0 else ""
224
+
225
+ # Build stage layer HTML if stage image is set
226
+ stage_layer_html = ""
227
+ if scene.stage_url:
228
+ stage_layer_html = f'<div class="stage-layer" style="background-image:url(\'{scene.stage_url}\'); {stage_blur_style}"></div>'
229
+
230
+ stage_html = f"""
231
+ <div class="stage">
232
+ <div class="stage-background" style="background-image:url('{scene.background_url}'); {bg_blur_style}"></div>
233
+ {stage_layer_html}
234
+ {''.join(char_layers)}
235
+ {bubble_html}
236
+ </div>
237
+ """
238
+ return (
239
+ stage_html,
240
+ dialogue_markdown,
241
+ metadata,
242
+ scene.show_camera,
243
+ scene.show_voice,
244
+ scene.show_motors,
245
+ scene.show_robot,
246
+ scene.choices,
247
+ scene.input_request,
248
+ )
249
+
250
+
251
+ def is_scene_accessible(scene: SceneState, active_paths: set) -> bool:
252
+ """Check if a scene is accessible given the active story paths."""
253
+ # Scenes with no path are always accessible (main path)
254
+ if scene.path is None:
255
+ return True
256
+ # Scenes with a specific path are only accessible if that path is active
257
+ return scene.path in active_paths
258
+
259
+
260
+ def change_scene(
261
+ story_state: dict, direction: int
262
+ ) -> tuple[dict, str, str, str, str, dict, str, dict, str, dict, str, dict, dict, str, dict, dict, dict, dict]:
263
+ scenes: List[SceneState] = story_state["scenes"]
264
+ variables = story_state.get("variables", {})
265
+ active_paths = story_state.get("active_paths", set())
266
+
267
+ if not scenes:
268
+ return (
269
+ story_state,
270
+ "",
271
+ "No scenes available.",
272
+ "",
273
+ camera_hint_text(False),
274
+ gr.update(visible=False),
275
+ voice_hint_text(False),
276
+ gr.update(visible=False),
277
+ motor_hint_text(False),
278
+ gr.update(visible=False),
279
+ robot_hint_text(False),
280
+ gr.update(visible=False),
281
+ gr.update(visible=False, choices=[]),
282
+ gr.update(visible=False),
283
+ gr.update(interactive=True),
284
+ gr.update(interactive=True),
285
+ gr.update(visible=False), # right_column
286
+ )
287
+
288
+ total = len(scenes)
289
+ current_index = story_state["index"]
290
+
291
+ # Find the next accessible scene in the given direction
292
+ new_index = current_index
293
+ search_index = current_index + direction
294
+
295
+ while 0 <= search_index < total:
296
+ if is_scene_accessible(scenes[search_index], active_paths):
297
+ new_index = search_index
298
+ break
299
+ search_index += direction
300
+
301
+ story_state["index"] = new_index
302
+ html, dialogue, meta, show_camera, show_voice, show_motors, show_robot, choices, input_req = render_scene(
303
+ scenes[story_state["index"]], story_state["index"], total, variables
304
+ )
305
+
306
+ # Disable navigation when choices or input are present
307
+ nav_enabled = not bool(choices) and not bool(input_req)
308
+
309
+ # Show right column if any feature is active
310
+ right_column_visible = show_camera or show_voice or show_motors or show_robot
311
+
312
+ return (
313
+ story_state,
314
+ html,
315
+ dialogue,
316
+ meta,
317
+ camera_hint_text(show_camera),
318
+ gr.update(visible=show_camera),
319
+ voice_hint_text(show_voice),
320
+ gr.update(visible=show_voice),
321
+ motor_hint_text(show_motors),
322
+ gr.update(visible=show_motors),
323
+ robot_hint_text(show_robot),
324
+ gr.update(visible=show_robot),
325
+ gr.update(visible=bool(choices), choices=[(c.text, i) for i, c in enumerate(choices)] if choices else [], value=None),
326
+ f"### {input_req.prompt}" if input_req else "",
327
+ gr.update(visible=bool(input_req)),
328
+ gr.update(interactive=nav_enabled),
329
+ gr.update(interactive=nav_enabled),
330
+ gr.update(visible=right_column_visible), # right_column
331
+ )
332
+
333
+
334
+ def handle_choice(story_state: dict, choice_index: int) -> tuple[dict, str, str, str, str, dict, str, dict, str, dict, str, dict, dict, str, dict, dict, dict, dict]:
335
+ """Navigate to the scene selected by the choice."""
336
+ scenes: List[SceneState] = story_state["scenes"]
337
+ variables = story_state.get("variables", {})
338
+ active_paths = story_state.get("active_paths", set())
339
+ current_scene = scenes[story_state["index"]]
340
+
341
+ if current_scene.choices and 0 <= choice_index < len(current_scene.choices):
342
+ chosen = current_scene.choices[choice_index]
343
+ story_state["index"] = chosen.next_scene_index
344
+
345
+ # Activate the path of the chosen scene
346
+ target_scene = scenes[chosen.next_scene_index]
347
+ if target_scene.path:
348
+ active_paths = set(active_paths) # Copy the set
349
+ active_paths.add(target_scene.path)
350
+ story_state["active_paths"] = active_paths
351
+
352
+ html, dialogue, meta, show_camera, show_voice, show_motors, show_robot, choices, input_req = render_scene(
353
+ scenes[story_state["index"]], story_state["index"], len(scenes), variables
354
+ )
355
+
356
+ nav_enabled = not bool(choices) and not bool(input_req)
357
+ right_column_visible = show_camera or show_voice or show_motors or show_robot
358
+
359
+ return (
360
+ story_state,
361
+ html,
362
+ dialogue,
363
+ meta,
364
+ camera_hint_text(show_camera),
365
+ gr.update(visible=show_camera),
366
+ voice_hint_text(show_voice),
367
+ gr.update(visible=show_voice),
368
+ motor_hint_text(show_motors),
369
+ gr.update(visible=show_motors),
370
+ robot_hint_text(show_robot),
371
+ gr.update(visible=show_robot),
372
+ gr.update(visible=bool(choices), choices=[(c.text, i) for i, c in enumerate(choices)] if choices else [], value=None),
373
+ f"### {input_req.prompt}" if input_req else "",
374
+ gr.update(visible=bool(input_req)),
375
+ gr.update(interactive=nav_enabled),
376
+ gr.update(interactive=nav_enabled),
377
+ gr.update(visible=right_column_visible), # right_column
378
+ )
379
+ return change_scene(story_state, 0)
380
+
381
+
382
+ def handle_input(story_state: dict, user_input: str) -> tuple[dict, str, str, str, str, dict, str, dict, str, dict, str, dict, dict, str, dict, dict, dict, dict]:
383
+ """Store user input and advance to next scene."""
384
+ scenes: List[SceneState] = story_state["scenes"]
385
+ variables = story_state.get("variables", {})
386
+ current_scene = scenes[story_state["index"]]
387
+
388
+ if current_scene.input_request and user_input:
389
+ variables[current_scene.input_request.variable_name] = user_input
390
+ story_state["variables"] = variables
391
+
392
+ # Advance to next scene
393
+ story_state["index"] = min(story_state["index"] + 1, len(scenes) - 1)
394
+
395
+ html, dialogue, meta, show_camera, show_voice, show_motors, show_robot, choices, input_req = render_scene(
396
+ scenes[story_state["index"]], story_state["index"], len(scenes), variables
397
+ )
398
+
399
+ nav_enabled = not bool(choices) and not bool(input_req)
400
+ right_column_visible = show_camera or show_voice or show_motors or show_robot
401
+
402
+ return (
403
+ story_state,
404
+ html,
405
+ dialogue,
406
+ meta,
407
+ camera_hint_text(show_camera),
408
+ gr.update(visible=show_camera),
409
+ voice_hint_text(show_voice),
410
+ gr.update(visible=show_voice),
411
+ motor_hint_text(show_motors),
412
+ gr.update(visible=show_motors),
413
+ robot_hint_text(show_robot),
414
+ gr.update(visible=show_robot),
415
+ gr.update(visible=bool(choices), choices=[(c.text, i) for i, c in enumerate(choices)] if choices else [], value=None),
416
+ f"### {input_req.prompt}" if input_req else "",
417
+ gr.update(visible=bool(input_req)),
418
+ gr.update(interactive=nav_enabled),
419
+ gr.update(interactive=nav_enabled),
420
+ gr.update(visible=right_column_visible), # right_column
421
+ )
422
+
423
+
424
+ def load_initial_state() -> tuple[dict, str, str, str, str, dict, str, dict, str, dict, str, dict, dict, str, dict, dict, dict, dict]:
425
+ scenes = build_sample_story()
426
+ story_state = {"scenes": scenes, "index": 0, "variables": {}, "active_paths": set()}
427
+ if scenes:
428
+ html, dialogue, meta, show_camera, show_voice, show_motors, show_robot, choices, input_req = render_scene(
429
+ scenes[0], 0, len(scenes), {}
430
+ )
431
+ else:
432
+ html, dialogue, meta, show_camera, show_voice, show_motors, show_robot, choices, input_req = (
433
+ "",
434
+ "No scenes available.",
435
+ "",
436
+ False,
437
+ False,
438
+ False,
439
+ False,
440
+ None,
441
+ None,
442
+ )
443
+
444
+ nav_enabled = not bool(choices) and not bool(input_req)
445
+ right_column_visible = show_camera or show_voice or show_motors or show_robot
446
+
447
+ return (
448
+ story_state,
449
+ html,
450
+ dialogue,
451
+ meta,
452
+ camera_hint_text(show_camera),
453
+ gr.update(visible=show_camera),
454
+ voice_hint_text(show_voice),
455
+ gr.update(visible=show_voice),
456
+ motor_hint_text(show_motors),
457
+ gr.update(visible=show_motors),
458
+ robot_hint_text(show_robot),
459
+ gr.update(visible=show_robot),
460
+ gr.update(visible=bool(choices), choices=[(c.text, i) for i, c in enumerate(choices)] if choices else [], value=None),
461
+ f"### {input_req.prompt}" if input_req else "",
462
+ gr.update(visible=bool(input_req)),
463
+ gr.update(interactive=nav_enabled),
464
+ gr.update(interactive=nav_enabled),
465
+ gr.update(visible=right_column_visible), # right_column
466
+ )
467
+
468
+
469
+ CUSTOM_CSS = """
470
+ /* Override Gradio's height constraints for stage container */
471
+ #stage-container {
472
+ height: auto !important;
473
+ max-height: none !important;
474
+ }
475
+ #stage-container > div {
476
+ height: auto !important;
477
+ }
478
+ .stage {
479
+ width: 100%;
480
+ height: 80vh;
481
+ min-height: 600px;
482
+ border-radius: 0;
483
+ position: relative;
484
+ overflow: hidden;
485
+ box-shadow: 0 12px 32px rgba(15,23,42,0.45);
486
+ display: flex;
487
+ align-items: flex-end;
488
+ justify-content: center;
489
+ }
490
+ /* Ensure background layers fill the stage */
491
+ .stage-background,
492
+ .stage-layer {
493
+ max-height: none !important;
494
+ }
495
+ .stage-background {
496
+ position: absolute;
497
+ top: 0;
498
+ left: 0;
499
+ width: 100%;
500
+ height: 100%;
501
+ background-size: contain;
502
+ background-position: center;
503
+ background-repeat: no-repeat;
504
+ z-index: 0;
505
+ }
506
+ .stage-layer {
507
+ position: absolute;
508
+ top: 0;
509
+ left: 0;
510
+ width: 100%;
511
+ height: 100%;
512
+ background-size: contain;
513
+ background-position: center;
514
+ background-repeat: no-repeat;
515
+ z-index: 5;
516
+ }
517
+ .character {
518
+ position: absolute;
519
+ bottom: 0;
520
+ width: 200px;
521
+ height: 380px;
522
+ background-size: contain;
523
+ background-repeat: no-repeat;
524
+ --char-scale: 1.0;
525
+ transform: translateX(-50%) scale(var(--char-scale));
526
+ transition: transform 0.4s ease;
527
+ z-index: 10;
528
+ }
529
+ /* Character animations */
530
+ .character.anim-idle {
531
+ animation: anim-idle 4s ease-in-out infinite;
532
+ }
533
+ .character.anim-shake {
534
+ animation: anim-shake 0.5s ease-in-out;
535
+ }
536
+ .character.anim-bounce {
537
+ animation: anim-bounce 0.6s ease-in-out;
538
+ }
539
+ .character.anim-pulse {
540
+ animation: anim-pulse 1s ease-in-out infinite;
541
+ }
542
+ .speech-bubble {
543
+ position: absolute;
544
+ bottom: 18px;
545
+ left: 50%;
546
+ transform: translateX(-50%);
547
+ min-width: 60%;
548
+ max-width: 90%;
549
+ padding: 20px 24px;
550
+ border-radius: 20px;
551
+ background: rgba(15,23,42,0.88);
552
+ color: #f8fafc;
553
+ font-family: "Atkinson Hyperlegible", system-ui, sans-serif;
554
+ box-shadow: 0 10px 28px rgba(0,0,0,0.35);
555
+ z-index: 20;
556
+ }
557
+ .speech-bubble::after {
558
+ content: "";
559
+ position: absolute;
560
+ bottom: -16px;
561
+ left: 50%;
562
+ transform: translateX(-50%);
563
+ border-width: 16px 12px 0 12px;
564
+ border-style: solid;
565
+ border-color: rgba(15,23,42,0.88) transparent transparent transparent;
566
+ }
567
+ .bubble-speaker {
568
+ font-size: 0.85rem;
569
+ letter-spacing: 0.08em;
570
+ font-weight: 700;
571
+ text-transform: uppercase;
572
+ color: #facc15;
573
+ margin-bottom: 6px;
574
+ }
575
+ .bubble-text {
576
+ font-size: 1.05rem;
577
+ line-height: 1.5;
578
+ }
579
+ .camera-column {
580
+ position: relative;
581
+ min-height: 360px;
582
+ gap: 0.75rem;
583
+ }
584
+ .camera-hint {
585
+ font-size: 0.85rem;
586
+ color: #cbd5f5;
587
+ margin-bottom: 0.4rem;
588
+ }
589
+ #camera-wrapper {
590
+ width: 100%;
591
+ max-width: 320px;
592
+ }
593
+ #camera-wrapper > div {
594
+ border-radius: 18px;
595
+ background: rgba(15,23,42,0.88);
596
+ padding: 6px;
597
+ box-shadow: 0 12px 26px rgba(15,23,42,0.55);
598
+ }
599
+ #camera-wrapper video {
600
+ border-radius: 14px;
601
+ object-fit: cover;
602
+ box-shadow: 0 10px 30px rgba(0,0,0,0.4);
603
+ }
604
+ .dxl-card {
605
+ margin-top: 0.5rem;
606
+ padding: 1rem 1.2rem;
607
+ border-radius: 14px;
608
+ background: rgba(15,23,42,0.85);
609
+ color: #e2e8f0;
610
+ box-shadow: 0 10px 26px rgba(0,0,0,0.45);
611
+ }
612
+ .dxl-card h3 {
613
+ margin: 0 0 0.35rem 0;
614
+ }
615
+ .dxl-row {
616
+ display: flex;
617
+ gap: 0.6rem;
618
+ align-items: center;
619
+ margin-bottom: 0.5rem;
620
+ flex-wrap: wrap;
621
+ }
622
+ .dxl-row label {
623
+ font-size: 0.9rem;
624
+ color: #cbd5e1;
625
+ }
626
+ .dxl-row input[type="number"],
627
+ .dxl-row select,
628
+ .dxl-row input[type="range"] {
629
+ flex: 1;
630
+ min-width: 120px;
631
+ }
632
+ .dxl-btn {
633
+ padding: 0.5rem 0.8rem;
634
+ border-radius: 10px;
635
+ border: 1px solid rgba(148,163,184,0.4);
636
+ background: rgba(255,255,255,0.05);
637
+ color: #e2e8f0;
638
+ cursor: pointer;
639
+ transition: transform 0.1s ease, background 0.15s ease;
640
+ }
641
+ .dxl-btn.primary {
642
+ background: linear-gradient(120deg, #06b6d4, #2563eb);
643
+ border-color: rgba(59,130,246,0.5);
644
+ }
645
+ .dxl-btn:disabled {
646
+ opacity: 0.5;
647
+ cursor: not-allowed;
648
+ }
649
+ .dxl-btn:not(:disabled):hover {
650
+ transform: translateY(-1px);
651
+ }
652
+ .dxl-status {
653
+ font-size: 0.9rem;
654
+ color: #a5b4fc;
655
+ min-height: 1.2rem;
656
+ }
657
+ .input-prompt {
658
+ font-size: 1.1rem;
659
+ font-weight: 600;
660
+ color: #1e293b;
661
+ margin-bottom: 0.5rem;
662
+ }
663
+ @keyframes anim-idle {
664
+ 0% { transform: translate(-50%, 0px) scale(var(--char-scale)); }
665
+ 50% { transform: translate(-50%, 12px) scale(var(--char-scale)); }
666
+ 100% { transform: translate(-50%, 0px) scale(var(--char-scale)); }
667
+ }
668
+ @keyframes anim-shake {
669
+ 0%, 100% { transform: translate(-50%, 0) rotate(0deg) scale(var(--char-scale)); }
670
+ 10%, 30%, 50%, 70%, 90% { transform: translate(-52%, 0) rotate(-2deg) scale(var(--char-scale)); }
671
+ 20%, 40%, 60%, 80% { transform: translate(-48%, 0) rotate(2deg) scale(var(--char-scale)); }
672
+ }
673
+ @keyframes anim-bounce {
674
+ 0%, 100% { transform: translate(-50%, 0) scale(var(--char-scale)); }
675
+ 25% { transform: translate(-50%, -30px) scale(var(--char-scale)); }
676
+ 50% { transform: translate(-50%, 0) scale(var(--char-scale)); }
677
+ 75% { transform: translate(-50%, -15px) scale(var(--char-scale)); }
678
+ }
679
+ @keyframes anim-pulse {
680
+ 0%, 100% { transform: translate(-50%, 0) scale(var(--char-scale)); }
681
+ 50% { transform: translate(-50%, 0) scale(calc(var(--char-scale) * 1.05)); }
682
+ }
683
+ """
684
+
685
+ ENUMERATE_CAMERAS_JS = """
686
+ async (currentDevices) => {
687
+ if (!navigator.mediaDevices?.enumerateDevices) {
688
+ return currentDevices || [];
689
+ }
690
+ try {
691
+ const devices = await navigator.mediaDevices.enumerateDevices();
692
+ return devices
693
+ .filter((device) => device.kind === "videoinput")
694
+ .map((device, index) => ({
695
+ label: device.label || `Camera ${index + 1}`,
696
+ deviceId: device.deviceId || null,
697
+ }));
698
+ } catch (error) {
699
+ console.warn("enumerateDevices failed", error);
700
+ return currentDevices || [];
701
+ }
702
+ }
703
+ """
704
+
705
+ def load_dxl_script_js() -> str:
706
+ """Generate JavaScript to dynamically load the DXL script from static files."""
707
+ import time
708
+ timestamp = int(time.time())
709
+ return f"""
710
+ () => {{
711
+ const script = document.createElement('script');
712
+ script.type = 'module';
713
+ script.src = '/web/dxl_webserial.js?v={timestamp}';
714
+ script.onerror = () => console.error("[DXL] Failed to load motor control script");
715
+ document.head.appendChild(script);
716
+ }}
717
+ """
718
+
719
+
720
+ def dxl_send_and_receive_js() -> str:
721
+ """JavaScript to send packet bytes and receive response via Web Serial."""
722
+ return """
723
+ async (packet_bytes) => {
724
+ // Check if dxlSerial is available and connected
725
+ if (typeof window.dxlSerial === 'undefined' || !window.dxlSerial) {
726
+ console.error("[DXL] Serial not available - connect first");
727
+ return [];
728
+ }
729
+
730
+ if (!window.dxlSerial.connected) {
731
+ console.error("[DXL] Not connected to serial port");
732
+ return [];
733
+ }
734
+
735
+ try {
736
+ await window.dxlSerial.writeBytes(packet_bytes);
737
+ const response = await window.dxlSerial.readPacket(800);
738
+ return response;
739
+ } catch (err) {
740
+ console.error("[DXL] Communication error:", err.message);
741
+ return [];
742
+ }
743
+ }
744
+ """
745
+
746
+
747
+ def execute_motor_packets_js() -> str:
748
+ """JavaScript to execute pre-built motor packets."""
749
+ return """
750
+ async (packets) => {
751
+ if (!packets || packets.length === 0) {
752
+ return; // No packets to execute
753
+ }
754
+
755
+ // Check if serial is available
756
+ if (typeof window.dxlSerial === 'undefined' || !window.dxlSerial || !window.dxlSerial.connected) {
757
+ return; // Silently skip if not connected
758
+ }
759
+
760
+ // Execute each packet sequentially
761
+ for (const pkt of packets) {
762
+ try {
763
+ await window.dxlSerial.writeBytes(pkt);
764
+ await window.dxlSerial.readPacket(800);
765
+ } catch (err) {
766
+ console.error(`[Motors] Error:`, err.message);
767
+ }
768
+ }
769
+ }
770
+ """
771
+
772
+
773
+ def play_scene_audio_js() -> str:
774
+ """JavaScript to play audio file."""
775
+ return """
776
+ (audio_path) => {
777
+ if (!audio_path || audio_path === '') {
778
+ return; // No audio to play
779
+ }
780
+
781
+ // Create or reuse audio element
782
+ let audio = document.getElementById('scene-audio-player');
783
+ if (!audio) {
784
+ audio = new Audio();
785
+ audio.id = 'scene-audio-player';
786
+ }
787
+
788
+ console.log('[Audio] Playing:', audio_path);
789
+ audio.src = audio_path;
790
+ audio.play().catch(err => console.error('[Audio] Playback failed:', err));
791
+ }
792
+ """
793
+
794
+
795
+ def load_robot_ws_script_js() -> str:
796
+ """JavaScript to initialize WebSocket connection to Reachy Mini robot."""
797
+ return """
798
+ () => {
799
+ console.log('[Robot] Initializing WebSocket connection...');
800
+
801
+ // Define global initialization function if not already defined
802
+ if (!window.loadRobotWebSocket) {
803
+ window.loadRobotWebSocket = function() {
804
+ const hostDiv = document.getElementById('robot-ws-host');
805
+ if (!hostDiv) {
806
+ console.error('[Robot] Cannot initialize - host div not found');
807
+ return;
808
+ }
809
+
810
+ const ROBOT_URL = 'localhost:8000';
811
+ const WS_URL = `ws://${ROBOT_URL}/api/move/ws/set_target`;
812
+
813
+ console.log('[Robot] Connecting to:', WS_URL);
814
+
815
+ // Global robot state
816
+ window.reachyRobot = {
817
+ ws: null,
818
+ connected: false
819
+ };
820
+
821
+ // Create UI
822
+ hostDiv.innerHTML = `
823
+ <div id="robot-connection-status" style="padding: 8px; border-radius: 4px; background: #f8d7da; color: #721c24; margin-bottom: 10px;">
824
+ <span id="robot-status-dot" style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #dc3545; margin-right: 6px;"></span>
825
+ <span id="robot-status-text">Disconnected - Trying to connect...</span>
826
+ </div>
827
+ `;
828
+
829
+ function updateStatus(connected) {
830
+ const statusDiv = document.getElementById('robot-connection-status');
831
+ const dot = document.getElementById('robot-status-dot');
832
+ const text = document.getElementById('robot-status-text');
833
+
834
+ if (connected) {
835
+ statusDiv.style.background = '#d4edda';
836
+ statusDiv.style.color = '#155724';
837
+ dot.style.background = '#28a745';
838
+ dot.style.boxShadow = '0 0 10px #28a745';
839
+ text.textContent = 'Connected to robot';
840
+ } else {
841
+ statusDiv.style.background = '#f8d7da';
842
+ statusDiv.style.color = '#721c24';
843
+ dot.style.background = '#dc3545';
844
+ dot.style.boxShadow = 'none';
845
+ text.textContent = 'Disconnected - Reconnecting...';
846
+ }
847
+ }
848
+
849
+ function connectWebSocket() {
850
+ console.log('[Robot] Connecting to WebSocket:', WS_URL);
851
+
852
+ window.reachyRobot.ws = new WebSocket(WS_URL);
853
+
854
+ window.reachyRobot.ws.onopen = () => {
855
+ console.log('[Robot] WebSocket connected');
856
+ window.reachyRobot.connected = true;
857
+ updateStatus(true);
858
+ };
859
+
860
+ window.reachyRobot.ws.onclose = () => {
861
+ console.log('[Robot] WebSocket disconnected');
862
+ window.reachyRobot.connected = false;
863
+ updateStatus(false);
864
+ // Reconnect after 2 seconds
865
+ setTimeout(connectWebSocket, 2000);
866
+ };
867
+
868
+ window.reachyRobot.ws.onerror = (error) => {
869
+ console.error('[Robot] WebSocket error:', error);
870
+ };
871
+
872
+ window.reachyRobot.ws.onmessage = (event) => {
873
+ try {
874
+ const message = JSON.parse(event.data);
875
+ if (message.status === 'error') {
876
+ console.error('[Robot] Server error:', message.detail);
877
+ }
878
+ } catch (e) {
879
+ console.error('[Robot] Failed to parse message:', e);
880
+ }
881
+ };
882
+ }
883
+
884
+ connectWebSocket();
885
+ }; // End of window.loadRobotWebSocket definition
886
+ }
887
+
888
+ // Try to initialize (with multiple retries)
889
+ let retryCount = 0;
890
+ const maxRetries = 10;
891
+
892
+ function tryInit() {
893
+ const hostDiv = document.getElementById('robot-ws-host');
894
+ if (!hostDiv) {
895
+ retryCount++;
896
+ if (retryCount <= maxRetries) {
897
+ console.warn(`[Robot] Host div not found, retry ${retryCount}/${maxRetries} in 1 second`);
898
+ setTimeout(tryInit, 1000);
899
+ } else {
900
+ console.warn('[Robot] Gave up waiting for robot widget div. Will initialize on first use.');
901
+ }
902
+ return;
903
+ }
904
+
905
+ if (window.reachyRobot) {
906
+ console.log('[Robot] Already initialized');
907
+ return;
908
+ }
909
+
910
+ // Initialize now
911
+ console.log('[Robot] Found host div, initializing...');
912
+ window.loadRobotWebSocket();
913
+ }
914
+
915
+ tryInit();
916
+ }
917
+ """
918
+
919
+
920
+ def send_robot_pose_js() -> str:
921
+ """JavaScript to send robot pose via WebSocket."""
922
+ return """
923
+ async (pose_data) => {
924
+ if (!pose_data) {
925
+ return; // No pose to send
926
+ }
927
+
928
+ // Initialize WebSocket if not already done (lazy initialization)
929
+ if (!window.reachyRobot) {
930
+ console.log('[Robot] Lazy initialization on first pose send');
931
+ if (window.loadRobotWebSocket) {
932
+ window.loadRobotWebSocket();
933
+ // Wait a bit for connection to establish
934
+ await new Promise(resolve => setTimeout(resolve, 500));
935
+ }
936
+ }
937
+
938
+ if (!window.reachyRobot || !window.reachyRobot.connected || !window.reachyRobot.ws || window.reachyRobot.ws.readyState !== WebSocket.OPEN) {
939
+ console.warn('[Robot] WebSocket not connected, skipping pose command');
940
+ return;
941
+ }
942
+
943
+ try {
944
+ console.log('[Robot] Sending pose:', pose_data);
945
+ window.reachyRobot.ws.send(JSON.stringify(pose_data));
946
+ } catch (error) {
947
+ console.error('[Robot] Failed to send pose:', error);
948
+ }
949
+ }
950
+ """
951
+
952
+
953
+
954
+ def build_app() -> gr.Blocks:
955
+ with gr.Blocks(title="Gradio Visual Novel") as demo:
956
+ gr.HTML(f"<style>{CUSTOM_CSS}</style>", elem_id="vn-styles")
957
+ story_state = gr.State()
958
+
959
+ with gr.Row():
960
+ with gr.Column(scale=3, min_width=640):
961
+ stage = gr.HTML(label="Stage", elem_id="stage-container")
962
+ dialogue = gr.Markdown(label="Dialogue")
963
+ meta = gr.Markdown(label="Scene Info", elem_id="scene-info")
964
+
965
+ # Choice selection
966
+ choice_radio = gr.Radio(label="Make a choice", visible=False)
967
+
968
+ # Text input
969
+ with gr.Group(visible=False) as input_group:
970
+ input_prompt = gr.Markdown("", elem_classes=["input-prompt"])
971
+ with gr.Row():
972
+ user_input = gr.Textbox(label="Your answer", scale=4)
973
+ input_submit_btn = gr.Button("Submit", variant="primary", scale=1)
974
+
975
+ with gr.Row():
976
+ prev_btn = gr.Button("⟵ Back", variant="secondary")
977
+ next_btn = gr.Button("Next ⟶", variant="primary")
978
+ with gr.Column(scale=1, min_width=320, elem_classes=["camera-column"], visible=False) as right_column:
979
+ gr.Markdown("### Live Camera (WebRTC)")
980
+ camera_hint = gr.Markdown(
981
+ camera_hint_text(False), elem_classes=["camera-hint"]
982
+ )
983
+ gr.Markdown(
984
+ "Allow camera access when prompted. The webcam appears only in scenes that request it.",
985
+ elem_classes=["camera-hint"],
986
+ )
987
+ with gr.Group(elem_id="camera-wrapper"):
988
+ webrtc_component = WebRTC(
989
+ label="Webcam Stream",
990
+ mode="send-receive",
991
+ modality="video",
992
+ full_screen=False,
993
+ visible=False,
994
+ )
995
+ webrtc_component.stream(
996
+ fn=passthrough_stream,
997
+ inputs=[webrtc_component],
998
+ outputs=[webrtc_component],
999
+ )
1000
+ voice_hint = gr.Markdown(
1001
+ voice_hint_text(False), elem_classes=["camera-hint"]
1002
+ )
1003
+ with gr.Group(visible=False, elem_id="voice-wrapper") as voice_section:
1004
+ with gr.Accordion("Voice & Audio Agent", open=True):
1005
+ gr.Markdown(
1006
+ "Record a short line to pass to your AI companion. "
1007
+ "We play back your clip and a synthetic confirmation tone.",
1008
+ elem_classes=["camera-hint"],
1009
+ )
1010
+ voice_prompt = gr.Textbox(
1011
+ label="Prompt/context",
1012
+ value="React to the current scene with a friendly reply.",
1013
+ lines=2,
1014
+ )
1015
+ mic = gr.Audio(
1016
+ sources=["microphone", "upload"],
1017
+ type="numpy",
1018
+ label="Record or upload audio",
1019
+ )
1020
+ send_voice_btn = gr.Button(
1021
+ "Send to voice agent", variant="secondary"
1022
+ )
1023
+ voice_summary = gr.Markdown("No audio captured yet.")
1024
+ playback = gr.Audio(label="Your recording", interactive=False)
1025
+ ai_voice_text = gr.Markdown("AI response will appear here.")
1026
+ ai_voice_audio = gr.Audio(
1027
+ label="AI voice reply (synthetic tone)", interactive=False
1028
+ )
1029
+ send_voice_btn.click(
1030
+ fn=process_voice_interaction,
1031
+ inputs=[mic, voice_prompt],
1032
+ outputs=[
1033
+ voice_summary,
1034
+ playback,
1035
+ ai_voice_text,
1036
+ ai_voice_audio,
1037
+ ],
1038
+ )
1039
+ motor_hint = gr.Markdown(
1040
+ motor_hint_text(False), elem_classes=["camera-hint"]
1041
+ )
1042
+ with gr.Group(visible=False, elem_id="dxl-panel-container") as motor_group:
1043
+ with gr.Accordion("Dynamixel XL330 Control", open=True):
1044
+ gr.Markdown(
1045
+ "**Web Serial Control** - Use Chrome/Edge desktop. Connect to serial port, then control motors.",
1046
+ elem_classes=["camera-hint"],
1047
+ )
1048
+
1049
+ # Serial connection panel (still handled by JavaScript)
1050
+ gr.HTML('<div id="dxl-panel-host"></div>', elem_id="dxl-panel-host-wrapper")
1051
+
1052
+ # Motor control inputs (Python-based)
1053
+ with gr.Row():
1054
+ motor_id_input = gr.Number(
1055
+ label="Motor ID",
1056
+ value=1,
1057
+ minimum=0,
1058
+ maximum=252,
1059
+ precision=0,
1060
+ )
1061
+ with gr.Row():
1062
+ goal_slider = gr.Slider(
1063
+ label="Goal Position (degrees)",
1064
+ minimum=0,
1065
+ maximum=360,
1066
+ value=90,
1067
+ step=1,
1068
+ )
1069
+ with gr.Row():
1070
+ ping_btn = gr.Button("Ping", size="sm")
1071
+ torque_on_btn = gr.Button("Torque ON", size="sm", variant="secondary")
1072
+ torque_off_btn = gr.Button("Torque OFF", size="sm")
1073
+ with gr.Row():
1074
+ send_goal_btn = gr.Button("Send Goal Position", variant="primary")
1075
+ motor_status = gr.Markdown("Status: Ready")
1076
+
1077
+ # Robot Control (Reachy Mini via WebSocket)
1078
+ robot_hint = gr.Markdown(
1079
+ robot_hint_text(False), elem_classes=["camera-hint"]
1080
+ )
1081
+ with gr.Group(visible=False, elem_id="robot-panel-container") as robot_group:
1082
+ with gr.Accordion("Reachy Mini Robot Control", open=True):
1083
+ gr.Markdown(
1084
+ "**WebSocket Control** - Connects to localhost:8000 for real-time robot control.",
1085
+ elem_classes=["camera-hint"],
1086
+ )
1087
+
1088
+ # WebSocket connection area (will be managed by JavaScript)
1089
+ # Status is shown dynamically by JavaScript inside this div
1090
+ gr.HTML('<div id="robot-ws-host"></div>', elem_id="robot-ws-host-wrapper")
1091
+
1092
+ # Wire up event handlers
1093
+ all_outputs = [
1094
+ story_state,
1095
+ stage,
1096
+ dialogue,
1097
+ meta,
1098
+ camera_hint,
1099
+ webrtc_component,
1100
+ voice_hint,
1101
+ voice_section,
1102
+ motor_hint,
1103
+ motor_group,
1104
+ robot_hint,
1105
+ robot_group,
1106
+ choice_radio,
1107
+ input_prompt,
1108
+ input_group,
1109
+ prev_btn,
1110
+ next_btn,
1111
+ right_column,
1112
+ ]
1113
+
1114
+ # Hidden JSON for passing packet bytes between Python and JavaScript
1115
+ # Note: gr.State doesn't work well with JavaScript, so we use JSON
1116
+ packet_bytes_json = gr.JSON(visible=False, value=[])
1117
+ response_bytes_json = gr.JSON(visible=False, value=[])
1118
+ motor_packets_json = gr.JSON(visible=False, value=[]) # For scene motor commands
1119
+
1120
+ # Hidden textbox for passing audio path to JavaScript
1121
+ audio_path_box = gr.Textbox(visible=False, value="")
1122
+
1123
+ # Hidden JSON for passing robot pose to JavaScript
1124
+ robot_pose_json = gr.JSON(visible=False, value=None)
1125
+
1126
+ # Load initialization scripts
1127
+ combined_init_js = f"""
1128
+ () => {{
1129
+ // Initialize Dynamixel
1130
+ ({load_dxl_script_js()})();
1131
+ // Initialize Robot WebSocket
1132
+ ({load_robot_ws_script_js()})();
1133
+ }}
1134
+ """
1135
+
1136
+ demo.load(
1137
+ fn=load_initial_state,
1138
+ inputs=None,
1139
+ outputs=all_outputs,
1140
+ js=combined_init_js,
1141
+ )
1142
+
1143
+ # Navigation buttons with automatic motor command execution, audio playback, and robot control
1144
+ # Create parallel chains for audio, motors, and robot to ensure all get the updated state
1145
+
1146
+ # Previous button
1147
+ prev_event = prev_btn.click(
1148
+ fn=lambda state: change_scene(state, -1),
1149
+ inputs=story_state,
1150
+ outputs=all_outputs,
1151
+ )
1152
+ # Audio chain
1153
+ prev_event.then(
1154
+ fn=get_scene_audio,
1155
+ inputs=[story_state],
1156
+ outputs=[audio_path_box],
1157
+ ).then(
1158
+ fn=None,
1159
+ inputs=[audio_path_box],
1160
+ outputs=[],
1161
+ js=play_scene_audio_js(),
1162
+ )
1163
+ # Motor chain (parallel)
1164
+ prev_event.then(
1165
+ fn=get_scene_motor_packets,
1166
+ inputs=[story_state],
1167
+ outputs=[motor_packets_json],
1168
+ ).then(
1169
+ fn=None,
1170
+ inputs=[motor_packets_json],
1171
+ outputs=[],
1172
+ js=execute_motor_packets_js(),
1173
+ )
1174
+ # Robot chain (parallel)
1175
+ prev_event.then(
1176
+ fn=get_scene_robot_pose,
1177
+ inputs=[story_state],
1178
+ outputs=[robot_pose_json],
1179
+ ).then(
1180
+ fn=None,
1181
+ inputs=[robot_pose_json],
1182
+ outputs=[],
1183
+ js=send_robot_pose_js(),
1184
+ )
1185
+
1186
+ # Next button
1187
+ next_event = next_btn.click(
1188
+ fn=lambda state: change_scene(state, 1),
1189
+ inputs=story_state,
1190
+ outputs=all_outputs,
1191
+ )
1192
+ # Audio chain
1193
+ next_event.then(
1194
+ fn=get_scene_audio,
1195
+ inputs=[story_state],
1196
+ outputs=[audio_path_box],
1197
+ ).then(
1198
+ fn=None,
1199
+ inputs=[audio_path_box],
1200
+ outputs=[],
1201
+ js=play_scene_audio_js(),
1202
+ )
1203
+ # Motor chain (parallel)
1204
+ next_event.then(
1205
+ fn=get_scene_motor_packets,
1206
+ inputs=[story_state],
1207
+ outputs=[motor_packets_json],
1208
+ ).then(
1209
+ fn=None,
1210
+ inputs=[motor_packets_json],
1211
+ outputs=[],
1212
+ js=execute_motor_packets_js(),
1213
+ )
1214
+ # Robot chain (parallel)
1215
+ next_event.then(
1216
+ fn=get_scene_robot_pose,
1217
+ inputs=[story_state],
1218
+ outputs=[robot_pose_json],
1219
+ ).then(
1220
+ fn=None,
1221
+ inputs=[robot_pose_json],
1222
+ outputs=[],
1223
+ js=send_robot_pose_js(),
1224
+ )
1225
+
1226
+ # Choice handler
1227
+ choice_event = choice_radio.change(
1228
+ fn=handle_choice,
1229
+ inputs=[story_state, choice_radio],
1230
+ outputs=all_outputs,
1231
+ )
1232
+ # Audio chain
1233
+ choice_event.then(
1234
+ fn=get_scene_audio,
1235
+ inputs=[story_state],
1236
+ outputs=[audio_path_box],
1237
+ ).then(
1238
+ fn=None,
1239
+ inputs=[audio_path_box],
1240
+ outputs=[],
1241
+ js=play_scene_audio_js(),
1242
+ )
1243
+ # Motor chain (parallel)
1244
+ choice_event.then(
1245
+ fn=get_scene_motor_packets,
1246
+ inputs=[story_state],
1247
+ outputs=[motor_packets_json],
1248
+ ).then(
1249
+ fn=None,
1250
+ inputs=[motor_packets_json],
1251
+ outputs=[],
1252
+ js=execute_motor_packets_js(),
1253
+ )
1254
+ # Robot chain (parallel)
1255
+ choice_event.then(
1256
+ fn=get_scene_robot_pose,
1257
+ inputs=[story_state],
1258
+ outputs=[robot_pose_json],
1259
+ ).then(
1260
+ fn=None,
1261
+ inputs=[robot_pose_json],
1262
+ outputs=[],
1263
+ js=send_robot_pose_js(),
1264
+ )
1265
+
1266
+ # Input submit button
1267
+ input_submit_event = input_submit_btn.click(
1268
+ fn=handle_input,
1269
+ inputs=[story_state, user_input],
1270
+ outputs=all_outputs,
1271
+ )
1272
+ # Audio chain
1273
+ input_submit_event.then(
1274
+ fn=get_scene_audio,
1275
+ inputs=[story_state],
1276
+ outputs=[audio_path_box],
1277
+ ).then(
1278
+ fn=None,
1279
+ inputs=[audio_path_box],
1280
+ outputs=[],
1281
+ js=play_scene_audio_js(),
1282
+ )
1283
+ # Motor chain (parallel)
1284
+ input_submit_event.then(
1285
+ fn=get_scene_motor_packets,
1286
+ inputs=[story_state],
1287
+ outputs=[motor_packets_json],
1288
+ ).then(
1289
+ fn=None,
1290
+ inputs=[motor_packets_json],
1291
+ outputs=[],
1292
+ js=execute_motor_packets_js(),
1293
+ )
1294
+ # Robot chain (parallel)
1295
+ input_submit_event.then(
1296
+ fn=get_scene_robot_pose,
1297
+ inputs=[story_state],
1298
+ outputs=[robot_pose_json],
1299
+ ).then(
1300
+ fn=None,
1301
+ inputs=[robot_pose_json],
1302
+ outputs=[],
1303
+ js=send_robot_pose_js(),
1304
+ )
1305
+
1306
+ # Input enter key
1307
+ input_enter_event = user_input.submit(
1308
+ fn=handle_input,
1309
+ inputs=[story_state, user_input],
1310
+ outputs=all_outputs,
1311
+ )
1312
+ # Audio chain
1313
+ input_enter_event.then(
1314
+ fn=get_scene_audio,
1315
+ inputs=[story_state],
1316
+ outputs=[audio_path_box],
1317
+ ).then(
1318
+ fn=None,
1319
+ inputs=[audio_path_box],
1320
+ outputs=[],
1321
+ js=play_scene_audio_js(),
1322
+ )
1323
+ # Motor chain (parallel)
1324
+ input_enter_event.then(
1325
+ fn=get_scene_motor_packets,
1326
+ inputs=[story_state],
1327
+ outputs=[motor_packets_json],
1328
+ ).then(
1329
+ fn=None,
1330
+ inputs=[motor_packets_json],
1331
+ outputs=[],
1332
+ js=execute_motor_packets_js(),
1333
+ )
1334
+ # Robot chain (parallel)
1335
+ input_enter_event.then(
1336
+ fn=get_scene_robot_pose,
1337
+ inputs=[story_state],
1338
+ outputs=[robot_pose_json],
1339
+ ).then(
1340
+ fn=None,
1341
+ inputs=[robot_pose_json],
1342
+ outputs=[],
1343
+ js=send_robot_pose_js(),
1344
+ )
1345
+
1346
+ # Motor control event handlers
1347
+ # Pattern: Python builds packet -> JS sends/receives -> Python parses
1348
+
1349
+ # Ping button
1350
+ ping_btn.click(
1351
+ fn=dxl_build_ping_packet,
1352
+ inputs=[motor_id_input],
1353
+ outputs=[packet_bytes_json],
1354
+ ).then(
1355
+ fn=None,
1356
+ inputs=[packet_bytes_json],
1357
+ outputs=[response_bytes_json],
1358
+ js=dxl_send_and_receive_js(),
1359
+ ).then(
1360
+ fn=dxl_parse_response,
1361
+ inputs=[response_bytes_json],
1362
+ outputs=[motor_status],
1363
+ )
1364
+
1365
+ # Torque ON button
1366
+ torque_on_btn.click(
1367
+ fn=lambda motor_id: dxl_build_torque_packet(motor_id, True),
1368
+ inputs=[motor_id_input],
1369
+ outputs=[packet_bytes_json],
1370
+ ).then(
1371
+ fn=None,
1372
+ inputs=[packet_bytes_json],
1373
+ outputs=[response_bytes_json],
1374
+ js=dxl_send_and_receive_js(),
1375
+ ).then(
1376
+ fn=dxl_parse_response,
1377
+ inputs=[response_bytes_json],
1378
+ outputs=[motor_status],
1379
+ )
1380
+
1381
+ # Torque OFF button
1382
+ torque_off_btn.click(
1383
+ fn=lambda motor_id: dxl_build_torque_packet(motor_id, False),
1384
+ inputs=[motor_id_input],
1385
+ outputs=[packet_bytes_json],
1386
+ ).then(
1387
+ fn=None,
1388
+ inputs=[packet_bytes_json],
1389
+ outputs=[response_bytes_json],
1390
+ js=dxl_send_and_receive_js(),
1391
+ ).then(
1392
+ fn=dxl_parse_response,
1393
+ inputs=[response_bytes_json],
1394
+ outputs=[motor_status],
1395
+ )
1396
+
1397
+ # Send goal position button
1398
+ send_goal_btn.click(
1399
+ fn=dxl_build_goal_position_packet,
1400
+ inputs=[motor_id_input, goal_slider],
1401
+ outputs=[packet_bytes_json],
1402
+ ).then(
1403
+ fn=None,
1404
+ inputs=[packet_bytes_json],
1405
+ outputs=[response_bytes_json],
1406
+ js=dxl_send_and_receive_js(),
1407
+ ).then(
1408
+ fn=dxl_parse_response,
1409
+ inputs=[response_bytes_json],
1410
+ outputs=[motor_status],
1411
+ )
1412
+
1413
+ return demo
1414
+
1415
+
1416
+ def main() -> None:
1417
+ """Launch the Visual Novel Gradio app with FastAPI for static file serving."""
1418
+ # Create FastAPI app
1419
+ fastapi_app = FastAPI()
1420
+
1421
+ # Mount static files for assets and web scripts
1422
+ script_dir = os.path.dirname(os.path.abspath(__file__))
1423
+ assets_dir = os.path.join(script_dir, "assets")
1424
+ web_dir = os.path.join(script_dir, "web")
1425
+ fastapi_app.mount("/user-assets", StaticFiles(directory=assets_dir), name="user-assets")
1426
+ fastapi_app.mount("/web", StaticFiles(directory=web_dir), name="web")
1427
+
1428
+ # Build and mount Gradio app
1429
+ gradio_app = build_app()
1430
+ fastapi_app = gr.mount_gradio_app(fastapi_app, gradio_app, path="/")
1431
+
1432
+ # Launch with proper shutdown handling
1433
+ import uvicorn
1434
+ try:
1435
+ uvicorn.run(
1436
+ fastapi_app,
1437
+ host="127.0.0.1",
1438
+ port=7860,
1439
+ log_level="info",
1440
+ timeout_graceful_shutdown=1 # Quick shutdown
1441
+ )
1442
+ except KeyboardInterrupt:
1443
+ print("\n[INFO] Server stopped")
1444
+
1445
+
1446
+
1447
+ if __name__ == "__main__":
1448
+ main()
pyproject.toml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "test-vn"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "gradio>=4.44.0",
9
+ "fastrtc>=0.0.34",
10
+ "numpy>=1.26.0",
11
+ ]
story.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sample Story - Example visual novel story with branching paths."""
2
+
3
+ import copy
4
+ from typing import List
5
+
6
+ from engine import (
7
+ VisualNovelBuilder,
8
+ SceneState,
9
+ CharacterDefinition,
10
+ Choice,
11
+ background_asset,
12
+ sprite_asset,
13
+ audio_asset,
14
+ create_sprite_data_url,
15
+ )
16
+
17
+
18
+ def build_sample_story() -> List[SceneState]:
19
+ """Build the sample story with branching paths."""
20
+ builder = VisualNovelBuilder()
21
+ builder.set_characters(
22
+ [
23
+ CharacterDefinition(
24
+ name="Ari",
25
+ image_url=sprite_asset('reachy-mini-cartoon.svg'),
26
+ ),
27
+ CharacterDefinition(
28
+ name="Bo",
29
+ image_url=sprite_asset('ReachyMini_emotions_happy.svg'),
30
+ animated=True,
31
+ ),
32
+ ]
33
+ )
34
+ builder.set_background(
35
+ background_asset('workshop_bg.png'),
36
+ )
37
+ builder.set_stage(background_asset("p60-back-cover.png"))
38
+
39
+ builder.narration("A hush falls over the academy courtyard as the gates creak open.")
40
+ builder.set_stage(
41
+ background_asset('p3.png'),
42
+ )
43
+ # Request player name
44
+ builder.request_input("What is your name, traveler?", "player_name")
45
+
46
+ # After input, create a new state without input_request
47
+ state = builder._clone_state()
48
+ state.input_request = None # Clear the input request
49
+ state.note = "Continuing story"
50
+ builder._push_state(state)
51
+
52
+ builder.show_character("Ari", position="left")
53
+ builder.play_sound(audio_asset("wake_up.wav"))
54
+ builder.dialogue("Ari", "Welcome, {player_name}! I'm Ari, and this is Bo.")
55
+ builder.show_character("Bo", position="right")
56
+ builder.dialogue("Bo", "Nice to meet you, {player_name}. We're on a quest to find the star fragment.")
57
+ builder.dialogue("Ari", "Will you help us on our quest?")
58
+
59
+ # ACCEPT BRANCH - tag all scenes with path="accept"
60
+ accept_index = len(builder._states)
61
+ builder.set_path("accept")
62
+ builder.dialogue("Bo", "Excellent! We knew we could count on you, {player_name}!")
63
+ builder.move_character("Ari", position="center")
64
+ builder.narration("You join Ari and Bo on their adventure...")
65
+
66
+ # Demonstrate camera feature
67
+ builder.set_camera(True)
68
+ builder.dialogue("Ari", "First, let me see your face, {player_name}. The camera will help us verify your identity.")
69
+ builder.narration("The camera activates, showing your live feed...")
70
+
71
+ # Demonstrate voice feature
72
+ builder.set_camera(False)
73
+ builder.set_voice(True)
74
+ builder.dialogue("Bo", "Now, tell us about yourself using the voice recorder.")
75
+ builder.narration("You can now record or upload audio to interact with the companions.")
76
+
77
+ # Demonstrate motors feature
78
+ builder.set_voice(False)
79
+ builder.set_motors(True)
80
+ builder.dialogue("Ari", "Finally, we need to test the portal controls. Use the motor panel to align the crystals.")
81
+ builder.narration("Motor controls are now available. Adjust the servos to proceed.")
82
+
83
+ # Example: Send motor commands from the story
84
+ builder.send_motor_command(1, 90) # Move motor ID 1 to 90 degrees
85
+ builder.dialogue("Ari", "Watch as the first crystal aligns itself!")
86
+
87
+ # Example: Send multiple motor commands at once
88
+ builder.send_motor_commands([(1, 180), (2, 90)]) # Move motors 1 and 2
89
+ builder.dialogue("Ari", "Now the portal crystals are synchronizing!")
90
+
91
+ # Example: Play sound effect
92
+ builder.play_sound(audio_asset("confused1.wav")) # Uncomment when you add audio files
93
+ builder.dialogue("Ari", "Listen! The portal resonates with magical energy!")
94
+
95
+ # Demonstrate robot control (Reachy Mini)
96
+ builder.set_motors(False)
97
+ builder.set_robot(True)
98
+ builder.dialogue("Bo", "Now let's test the Reachy Mini robot! It should be at localhost:8000.")
99
+ builder.narration("The robot control panel appears. Make sure your Reachy Mini server is running.")
100
+
101
+ # Send robot pose command - head looking up and antennas raised
102
+ builder.send_robot_pose(
103
+ head_x=0.0,
104
+ head_y=0.0,
105
+ head_z=0.02, # Raise head 2cm
106
+ head_pitch=-0.1, # Look up (negative pitch)
107
+ antenna_left=-0.2, # Raise left antenna
108
+ antenna_right=0.2, # Raise right antenna
109
+ )
110
+ builder.dialogue("Ari", "Watch! The robot looks up in wonder!")
111
+
112
+ # Send another pose - head tilted
113
+ builder.send_robot_pose(
114
+ head_z=-0.04, # Raise head 2cm
115
+ head_roll=0.1, # Tilt head to the side
116
+ head_yaw=0.1, # Turn head slightly
117
+ antenna_left=-0.3, # Lower left antenna
118
+ antenna_right=0.8, # Raise right antenna more
119
+ )
120
+ builder.dialogue("Bo", "The robot is expressing curiosity!")
121
+
122
+ # Demonstrate stage layer with separate blur
123
+ builder.set_robot(False)
124
+ builder.set_stage(background_asset('p3.png')) # Add a stage layer
125
+ builder.dialogue("Ari", "Look! The portal is opening...")
126
+ builder.narration("A mystical stage appears between you and the background.")
127
+
128
+ # Demonstrate separate blur controls
129
+ builder.set_background_blur(8)
130
+ builder.set_stage_blur(3)
131
+ builder.dialogue("Ari", "Wait! Do you sense that? Something magical is happening...")
132
+ builder.narration("The background and stage blur independently as Ari steps forward.")
133
+
134
+ # Clear stage and blur
135
+ builder.set_background_blur(0)
136
+ builder.set_stage_blur(0)
137
+ builder.set_stage("") # Remove stage layer
138
+
139
+ # Demonstrate character animations and sprite changes
140
+ builder.set_character_animation("Bo", "shake")
141
+ builder.dialogue("Bo", "Whoa! Did you feel that tremor?!")
142
+
143
+ builder.set_character_animation("Bo", "bounce")
144
+ builder.dialogue("Bo", "This is so exciting! We're getting close!")
145
+
146
+ builder.set_character_animation("Bo", "")
147
+ builder.set_character_animation("Ari", "pulse")
148
+ builder.dialogue("Ari", "The star fragment... I can feel its power pulsing nearby.")
149
+
150
+ # Demonstrate character scaling
151
+ builder.set_character_animation("Ari", "")
152
+ builder.set_character_scale("Ari", 1.5)
153
+ builder.dialogue("Ari", "The power... it's making me grow stronger!")
154
+
155
+ builder.set_character_scale("Bo", 0.7)
156
+ builder.dialogue("Bo", "Whoa, you're getting really big! Or am I shrinking?")
157
+
158
+ # Reset scales
159
+ builder.set_character_scale("Ari", 1.0)
160
+ builder.set_character_scale("Bo", 1.0)
161
+
162
+ # Turn off all features
163
+ builder.set_motors(False)
164
+ builder.dialogue("Ari", "The portal is ready! But wait...")
165
+ builder.dialogue("Bo", "The path splits here! We need to split up to cover more ground.")
166
+ builder.dialogue("Ari", "You'll need to choose who to follow, {player_name}.")
167
+
168
+ # SECOND CHOICE - Follow Ari or Bo
169
+ # Remember the index before the branches
170
+ follow_ari_index = len(builder._states)
171
+
172
+ # FOLLOW ARI SUB-BRANCH
173
+ builder.set_path("accept.follow_ari")
174
+ builder.dialogue("Ari", "Wise choice! My path leads through the ancient library.")
175
+ builder.hide_character("Bo")
176
+ builder.move_character("Ari", position="center")
177
+ builder.set_background(background_asset('p3.png'))
178
+ builder.narration("Bo waves goodbye as you follow Ari into the misty corridors...")
179
+ builder.dialogue("Ari", "The fragment's energy is strongest here. Stay close!")
180
+ builder.set_character_animation("Ari", "pulse")
181
+ builder.send_motor_command(1, 45) # Different motor position for this path
182
+ builder.dialogue("Ari", "The ancient mechanisms are responding!")
183
+ builder.set_character_animation("Ari", "")
184
+ builder.narration("You discover the star fragment hidden in an ancient tome.")
185
+ builder.dialogue("Ari", "We did it, {player_name}! The knowledge was the key all along.")
186
+ builder.play_sound(audio_asset("wake_up.wav"))
187
+ builder.narration("✨ Ending: The Scholar's Path (Follow Ari)")
188
+
189
+ # FOLLOW BO SUB-BRANCH
190
+ follow_bo_index = len(builder._states)
191
+ builder.set_path("accept.follow_bo")
192
+ builder.dialogue("Bo", "Adventure time! My route goes through the crystal caves!")
193
+ builder.hide_character("Ari")
194
+ builder.move_character("Bo", position="center")
195
+ builder.set_background(background_asset('workshop_bg.png'))
196
+ builder.narration("Ari nods encouragingly as you follow Bo into the glowing caves...")
197
+ builder.dialogue("Bo", "Can you feel the energy? It's electric!")
198
+ builder.set_character_animation("Bo", "bounce")
199
+ builder.send_motor_commands([(1, 135), (2, 135)]) # Different motor positions
200
+ builder.dialogue("Bo", "The crystals are resonating! We're so close!")
201
+ builder.set_character_animation("Bo", "shake")
202
+ builder.narration("A powerful tremor shakes the cavern as the fragment reveals itself!")
203
+ builder.dialogue("Bo", "Whoa! Grab it, {player_name}!")
204
+ builder.set_character_animation("Bo", "")
205
+ builder.play_sound(audio_asset("wake_up.wav"))
206
+ builder.narration("✨ Ending: The Adventurer's Path (Follow Bo)")
207
+
208
+ # Insert the second choice scene before the sub-branches
209
+ second_choice_scene = copy.deepcopy(builder._states[follow_ari_index - 1])
210
+ second_choice_scene.choices = [
211
+ Choice(text="Follow Ari (Library)", next_scene_index=follow_ari_index),
212
+ Choice(text="Follow Bo (Caves)", next_scene_index=follow_bo_index),
213
+ ]
214
+ second_choice_scene.note = "Second Choice (2 paths)"
215
+ second_choice_scene.input_request = None
216
+ second_choice_scene.path = "accept" # This choice is within the accept path
217
+ builder._states[follow_ari_index - 1] = second_choice_scene
218
+
219
+ # DECLINE BRANCH - tag all scenes with path="decline"
220
+ decline_index = len(builder._states)
221
+ builder.set_path("decline")
222
+ builder.dialogue("Ari", "That's... disappointing, {player_name}.")
223
+ builder.hide_character("Bo")
224
+ builder.dialogue("Ari", "I guess we're on our own, Bo.")
225
+ builder.narration("Ari and Bo leave without you... (Decline path)")
226
+
227
+ # Insert the choice scene before the branches
228
+ choice_scene = copy.deepcopy(builder._states[accept_index - 1])
229
+ choice_scene.choices = [
230
+ Choice(text="Yes, I'll help!", next_scene_index=accept_index),
231
+ Choice(text="No, sorry.", next_scene_index=decline_index),
232
+ ]
233
+ choice_scene.note = "Choice (2 options)"
234
+ choice_scene.input_request = None # Make sure no input request on choice scene
235
+ choice_scene.path = None # Choice scene is on the main path
236
+ builder._states[accept_index - 1] = choice_scene
237
+
238
+ return builder.build()
web/dxl_webserial.js ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dynamixel Web Serial - Low-level serial port I/O only.
3
+ * Protocol logic handled in Python (dynamixel.py).
4
+ */
5
+
6
+ const PANEL_HTML = `
7
+ <div class="dxl-card" id="dxl-panel">
8
+ <h3>Dynamixel XL330 Control (Web Serial)</h3>
9
+ <p class="camera-hint">
10
+ Use Chrome/Edge desktop. Click Connect to pick your serial/USB adapter.
11
+ </p>
12
+ <div class="dxl-row">
13
+ <label for="dxl-baud">Baud</label>
14
+ <select id="dxl-baud">
15
+ <option value="57600">57600</option>
16
+ <option value="115200">115200</option>
17
+ <option value="1000000" selected>1000000</option>
18
+ <option value="2000000">2000000</option>
19
+ </select>
20
+ <button class="dxl-btn" id="dxl-connect">Connect serial</button>
21
+ </div>
22
+ <div class="dxl-status" id="dxl-status">Web Serial idle.</div>
23
+ </div>
24
+ `;
25
+
26
+ class DxlWebSerial {
27
+ constructor(statusNode) {
28
+ this.statusNode = statusNode;
29
+ this.port = null;
30
+ this.writer = null;
31
+ this.reader = null;
32
+ this.connected = false;
33
+ }
34
+
35
+ status(msg) {
36
+ if (this.statusNode) this.statusNode.textContent = msg;
37
+ }
38
+
39
+ async connect(baud) {
40
+ if (!("serial" in navigator)) {
41
+ this.status("Web Serial not supported.");
42
+ return false;
43
+ }
44
+ if (this.connected) {
45
+ await this.disconnect();
46
+ }
47
+ try {
48
+ this.port = await navigator.serial.requestPort();
49
+ await this.port.open({ baudRate: Number(baud) });
50
+ this.writer = this.port.writable.getWriter();
51
+ this.reader = this.port.readable.getReader();
52
+ this.connected = true;
53
+ this.status(`Connected at ${baud} bps.`);
54
+ return true;
55
+ } catch (err) {
56
+ console.error(err);
57
+ this.status(`Connect failed: ${err.message}`);
58
+ this.connected = false;
59
+ return false;
60
+ }
61
+ }
62
+
63
+ async disconnect() {
64
+ try {
65
+ if (this.writer) this.writer.releaseLock();
66
+ if (this.reader) this.reader.releaseLock();
67
+ if (this.port) await this.port.close();
68
+ } catch (err) {
69
+ console.warn("Close error", err);
70
+ } finally {
71
+ this.writer = null;
72
+ this.reader = null;
73
+ this.port = null;
74
+ this.connected = false;
75
+ this.status("Disconnected.");
76
+ }
77
+ }
78
+
79
+ async writeBytes(bytes) {
80
+ if (!this.writer) throw new Error("Not connected.");
81
+ await this.writer.write(new Uint8Array(bytes));
82
+ }
83
+
84
+ async readPacket(timeoutMs = 800) {
85
+ if (!this.reader) throw new Error("No reader");
86
+ const deadline = Date.now() + timeoutMs;
87
+ const buf = [];
88
+
89
+ while (Date.now() < deadline) {
90
+ const { value, done } = await this.reader.read();
91
+ if (done) break;
92
+ if (value) buf.push(...value);
93
+
94
+ // Look for Dynamixel Protocol 2.0 header and extract complete packet
95
+ for (let i = 0; i < buf.length - 7; i += 1) {
96
+ if (
97
+ buf[i] === 0xff &&
98
+ buf[i + 1] === 0xff &&
99
+ buf[i + 2] === 0xfd &&
100
+ buf[i + 3] === 0x00
101
+ ) {
102
+ const len = buf[i + 5] | (buf[i + 6] << 8);
103
+ const end = i + 7 + len - 1;
104
+ if (buf.length >= end + 1) {
105
+ return buf.slice(i, end + 1);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ throw new Error("No response");
111
+ }
112
+ }
113
+
114
+ // Global instance - expose on window for access from Gradio event handlers
115
+ let dxlSerial = null;
116
+ window.dxlSerial = null;
117
+
118
+ function mountDxlPanel() {
119
+ const host = document.getElementById("dxl-panel-host");
120
+
121
+ if (!host) return;
122
+
123
+ // If already mounted, just ensure window.dxlSerial exists
124
+ if (host.dataset.mounted === "1") {
125
+ if (!window.dxlSerial) {
126
+ const statusEl = document.getElementById("dxl-status");
127
+ if (statusEl) {
128
+ dxlSerial = new DxlWebSerial(statusEl);
129
+ window.dxlSerial = dxlSerial;
130
+ }
131
+ }
132
+ return;
133
+ }
134
+
135
+ host.dataset.mounted = "1";
136
+ host.innerHTML = PANEL_HTML;
137
+
138
+ const statusEl = document.getElementById("dxl-status");
139
+ const connectBtn = document.getElementById("dxl-connect");
140
+ const baudSelect = document.getElementById("dxl-baud");
141
+
142
+ dxlSerial = new DxlWebSerial(statusEl);
143
+ window.dxlSerial = dxlSerial; // Expose globally
144
+
145
+ connectBtn?.addEventListener("click", async () => {
146
+ const baud = Number(baudSelect.value);
147
+ if (dxlSerial.connected) {
148
+ await dxlSerial.disconnect();
149
+ connectBtn.textContent = "Connect serial";
150
+ connectBtn.classList.remove("primary");
151
+ } else {
152
+ const connected = await dxlSerial.connect(baud);
153
+ if (connected) {
154
+ connectBtn.textContent = "Disconnect";
155
+ connectBtn.classList.add("primary");
156
+ }
157
+ }
158
+ });
159
+ }
160
+
161
+ function mountWhenReady() {
162
+ mountDxlPanel();
163
+
164
+ const observer = new MutationObserver(() => {
165
+ mountDxlPanel();
166
+ });
167
+ observer.observe(document.body, { childList: true, subtree: true });
168
+
169
+ const pollInterval = setInterval(() => {
170
+ const host = document.getElementById("dxl-panel-host");
171
+ if (host && !host.dataset.mounted) {
172
+ const rect = host.getBoundingClientRect();
173
+ if (rect.width > 0 && rect.height > 0) {
174
+ mountDxlPanel();
175
+ }
176
+ }
177
+ if (host?.dataset.mounted === "1") {
178
+ clearInterval(pollInterval);
179
+ }
180
+ }, 500);
181
+ }
182
+
183
+ if (document.readyState === "loading") {
184
+ document.addEventListener("DOMContentLoaded", mountWhenReady);
185
+ } else {
186
+ mountWhenReady();
187
+ }