diff --git a/experiment-scripts/app.py b/experiment-scripts/app.py index ca7d0d8..b6d3760 100644 --- a/experiment-scripts/app.py +++ b/experiment-scripts/app.py @@ -1,22 +1,28 @@ # Run in dev mode using: # fastapi dev app.py # -# After starting the server, you can navigate to http://localhost:8000 to see the web interface. +# This will automatically start both the web server AND the UDP relay (server.py). +# After starting, navigate to http://localhost:8000 to see the web interface. # # Note: This requires the user to have the fastapi CLI tool installed. -# The user should be in the same directory as `app.py` as well as `index.html`. +# The user should be in the same directory as `app.py`, `server.py`, and `index.html`. import asyncio from contextlib import asynccontextmanager import socket +import subprocess +import os +import signal from fastapi import FastAPI, Request from fastapi.responses import FileResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel clients = set() +server_process = None # Will hold the server.py subprocess # Broadcast function to notify all SSE clients async def notify_clients(message: str): @@ -35,7 +41,7 @@ async def sock_recvfrom(nonblocking_sock, *pos, loop, **kw): finally: loop.remove_reader(nonblocking_sock.fileno()) -# Background task: UDP listener +# Background task: UDP listener for SSE clients async def udp_listener(): loop = asyncio.get_running_loop() sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -48,13 +54,100 @@ async def udp_listener(): message = data.decode() await notify_clients(message) +# Background task: Tracking data listener +async def tracking_listener(): + loop = asyncio.get_running_loop() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", 5002)) # Different port for tracking data + sock.setblocking(False) + + while True: + data, addr = await sock_recvfrom(sock, 1024 * 16, loop=loop) # Larger buffer for tracking data + + # Only record if tracking is active + if tracking_state["is_recording"]: + try: + message = data.decode() + + # Extract source IP from tagged data (added by server.py) + if message.startswith("SOURCE_IP:"): + parts = message.split("|", 1) + source_ip = parts[0].replace("SOURCE_IP:", "") + actual_data = parts[1] if len(parts) > 1 else "" + else: + # Fallback if data isn't tagged (shouldn't happen normally) + source_ip = addr[0] + actual_data = message + + # Only record data from the active player + if source_ip == tracking_state["active_player_ip"]: + parsed = parse_tracking_data(actual_data) + + if parsed: + import time + # Calculate time elapsed since experiment start + elapsed_time = time.time() - tracking_state["experiment_start_time"] + + # Add sample with metadata + sample = { + "timestamp": time.time(), + "elapsed_time": elapsed_time, + "current_word": current_word_state["word"], + "word_time_remaining": 0.0, + "data": parsed + } + + # Calculate word time remaining if word is active + if current_word_state["startTime"]: + word_elapsed = time.time() - current_word_state["startTime"] + sample["word_time_remaining"] = max(0, current_word_state["timeSeconds"] - word_elapsed) + + tracking_state["samples"].append(sample) + except Exception as e: + print(f"Error processing tracking data: {e}") + @asynccontextmanager async def lifespan(app: FastAPI): + global server_process + + # Start server.py subprocess + print("Starting UDP relay server (server.py)...") + try: + server_process = subprocess.Popen( + ["python", "server.py"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=os.path.dirname(os.path.abspath(__file__)) + ) + print(f"UDP relay server started with PID {server_process.pid}") + except Exception as e: + print(f"Warning: Failed to start server.py: {e}") + print("You may need to start server.py manually.") + + # Start background tasks asyncio.create_task(udp_listener()) + asyncio.create_task(tracking_listener()) + yield + + # Cleanup: Stop server.py subprocess + if server_process: + print("Stopping UDP relay server...") + server_process.terminate() + try: + server_process.wait(timeout=5) + print("UDP relay server stopped") + except subprocess.TimeoutExpired: + print("Forcefully killing UDP relay server...") + server_process.kill() + server_process.wait() app = FastAPI(lifespan=lifespan) +# Mount static files for serving CSS and JavaScript +app.mount("/static", StaticFiles(directory="."), name="static") + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # sock.bind(("0.0.0.0", 5002)) @@ -86,6 +179,17 @@ class Word(BaseModel): class WordList(BaseModel): words: list[str] +class VRConfig(BaseModel): + player1_ip: str + player2_ip: str + server_ip: str + mode: str + +class TrackingStart(BaseModel): + group_id: str + condition: str + active_player_ip: str + # Global state for current word display current_word_state = { "word": "", @@ -94,6 +198,97 @@ current_word_state = { "startTime": None } +# Global state for tracking data recording +tracking_state = { + "is_recording": False, + "group_id": "", + "condition": "", + "active_player_ip": "", + "experiment_start_time": None, + "samples": [] # List of tracking data samples +} + +def parse_tracking_data(data_str): + """ + Parse tracking data from VR headset to extract camera and controller positions/rotations. + Returns dict with center_eye, left_hand, right_hand data or None if parsing fails. + """ + try: + parts = data_str.split(';') + + # The data structure from TrackWeights.cs: + # 0-62: Face expressions (63 values) + # 63-66: Root orientation (4 values) + # 67-69: Root position (3 values) + # 70: Root scale (1 value) + # 71: Bone rotations length (1 value) + bone_rot_length = int(float(parts[71])) + # 72 to 72+(bone_rot_length*4)-1: Bone rotations (4 values each) + idx = 72 + (bone_rot_length * 4) + # idx: IsDataValid + idx += 1 + # idx: IsDataHighConfidence + idx += 1 + # idx: Bone translations length + bone_trans_length = int(float(parts[idx])) + idx += 1 + # idx to idx+(bone_trans_length*3)-1: Bone translations (3 values each) + idx += bone_trans_length * 3 + # idx: SkeletonChangedCount + idx += 1 + # idx to idx+3: Left eye rotation (4 values) + idx += 4 + # idx to idx+3: Right eye rotation (4 values) + idx += 4 + + # Now we're at the data we want! + # Center eye camera: position (3) + rotation (4) = 7 values + center_eye_pos_x = float(parts[idx]) + center_eye_pos_y = float(parts[idx + 1]) + center_eye_pos_z = float(parts[idx + 2]) + center_eye_rot_w = float(parts[idx + 3]) + center_eye_rot_x = float(parts[idx + 4]) + center_eye_rot_y = float(parts[idx + 5]) + center_eye_rot_z = float(parts[idx + 6]) + idx += 7 + + # Left hand controller: position (3) + rotation (4) = 7 values + left_hand_pos_x = float(parts[idx]) + left_hand_pos_y = float(parts[idx + 1]) + left_hand_pos_z = float(parts[idx + 2]) + left_hand_rot_w = float(parts[idx + 3]) + left_hand_rot_x = float(parts[idx + 4]) + left_hand_rot_y = float(parts[idx + 5]) + left_hand_rot_z = float(parts[idx + 6]) + idx += 7 + + # Right hand controller: position (3) + rotation (4) = 7 values + right_hand_pos_x = float(parts[idx]) + right_hand_pos_y = float(parts[idx + 1]) + right_hand_pos_z = float(parts[idx + 2]) + right_hand_rot_w = float(parts[idx + 3]) + right_hand_rot_x = float(parts[idx + 4]) + right_hand_rot_y = float(parts[idx + 5]) + right_hand_rot_z = float(parts[idx + 6]) + + return { + "center_eye": { + "pos": {"x": center_eye_pos_x, "y": center_eye_pos_y, "z": center_eye_pos_z}, + "rot": {"w": center_eye_rot_w, "x": center_eye_rot_x, "y": center_eye_rot_y, "z": center_eye_rot_z} + }, + "left_hand": { + "pos": {"x": left_hand_pos_x, "y": left_hand_pos_y, "z": left_hand_pos_z}, + "rot": {"w": left_hand_rot_w, "x": left_hand_rot_x, "y": left_hand_rot_y, "z": left_hand_rot_z} + }, + "right_hand": { + "pos": {"x": right_hand_pos_x, "y": right_hand_pos_y, "z": right_hand_pos_z}, + "rot": {"w": right_hand_rot_w, "x": right_hand_rot_x, "y": right_hand_rot_y, "z": right_hand_rot_z} + } + } + except (IndexError, ValueError) as e: + print(f"Error parsing tracking data: {e}") + return None + @app.post("/word") def read_word(word: Word): import time @@ -140,6 +335,150 @@ def shuffle_words(word_list: WordList): random.shuffle(shuffled) return { "status": "ok", "shuffled_words": shuffled } +@app.post("/send-config") +def send_vr_config(config: VRConfig): + """ + Send IP and MODE configuration to VR headsets. + This integrates the functionality from control.py into the web interface. + """ + try: + # Create UDP socket for sending commands + cmd_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # Send IP configuration to both players + ip_msg = f"IP:{config.server_ip}".encode('utf-8') + cmd_sock.sendto(ip_msg, (config.player1_ip, 5000)) + cmd_sock.sendto(ip_msg, (config.player2_ip, 5000)) + + # Send MODE configuration to both players + mode_msg = f"MODE:{config.mode}".encode('utf-8') + cmd_sock.sendto(mode_msg, (config.player1_ip, 5000)) + cmd_sock.sendto(mode_msg, (config.player2_ip, 5000)) + + cmd_sock.close() + + print(f"Sent IP config: {ip_msg.decode()}") + print(f"Sent MODE config: {mode_msg.decode()}") + print(f"To players: {config.player1_ip}, {config.player2_ip}") + + return { "status": "ok" } + except Exception as e: + print(f"Error sending VR config: {e}") + return { "status": "error", "message": str(e) } + +@app.post("/tracking/start") +def start_tracking(config: TrackingStart): + """ + Start recording tracking data from the active player. + """ + import time + + tracking_state["is_recording"] = True + tracking_state["group_id"] = config.group_id + tracking_state["condition"] = config.condition + tracking_state["active_player_ip"] = config.active_player_ip + tracking_state["experiment_start_time"] = time.time() + tracking_state["samples"] = [] + + print(f"Started tracking: group={config.group_id}, condition={config.condition}, player={config.active_player_ip}") + return { "status": "ok", "message": "Tracking started" } + +@app.post("/tracking/stop") +def stop_tracking(): + """ + Stop recording tracking data. + """ + tracking_state["is_recording"] = False + sample_count = len(tracking_state["samples"]) + print(f"Stopped tracking: {sample_count} samples recorded") + return { "status": "ok", "message": f"Tracking stopped. {sample_count} samples recorded." } + +@app.get("/tracking/status") +def get_tracking_status(): + """ + Get current tracking status. + """ + return { + "is_recording": tracking_state["is_recording"], + "sample_count": len(tracking_state["samples"]), + "group_id": tracking_state["group_id"], + "condition": tracking_state["condition"] + } + +@app.get("/tracking/download") +def download_tracking_csv(): + """ + Download tracking data as CSV. + """ + import io + from datetime import datetime + + if len(tracking_state["samples"]) == 0: + return { "status": "error", "message": "No tracking data available" } + + # Create CSV content + output = io.StringIO() + + # Write header + header = [ + "timestamp", "elapsed_time", "group_id", "condition", "current_word", "word_time_remaining", + "center_eye_pos_x", "center_eye_pos_y", "center_eye_pos_z", + "center_eye_rot_w", "center_eye_rot_x", "center_eye_rot_y", "center_eye_rot_z", + "left_hand_pos_x", "left_hand_pos_y", "left_hand_pos_z", + "left_hand_rot_w", "left_hand_rot_x", "left_hand_rot_y", "left_hand_rot_z", + "right_hand_pos_x", "right_hand_pos_y", "right_hand_pos_z", + "right_hand_rot_w", "right_hand_rot_x", "right_hand_rot_y", "right_hand_rot_z" + ] + output.write(";".join(header) + "\n") + + # Write data rows + for sample in tracking_state["samples"]: + data = sample["data"] + row = [ + str(sample["timestamp"]), + f"{sample['elapsed_time']:.4f}", + tracking_state["group_id"], + tracking_state["condition"], + sample["current_word"], + f"{sample['word_time_remaining']:.4f}", + f"{data['center_eye']['pos']['x']:.4f}", + f"{data['center_eye']['pos']['y']:.4f}", + f"{data['center_eye']['pos']['z']:.4f}", + f"{data['center_eye']['rot']['w']:.4f}", + f"{data['center_eye']['rot']['x']:.4f}", + f"{data['center_eye']['rot']['y']:.4f}", + f"{data['center_eye']['rot']['z']:.4f}", + f"{data['left_hand']['pos']['x']:.4f}", + f"{data['left_hand']['pos']['y']:.4f}", + f"{data['left_hand']['pos']['z']:.4f}", + f"{data['left_hand']['rot']['w']:.4f}", + f"{data['left_hand']['rot']['x']:.4f}", + f"{data['left_hand']['rot']['y']:.4f}", + f"{data['left_hand']['rot']['z']:.4f}", + f"{data['right_hand']['pos']['x']:.4f}", + f"{data['right_hand']['pos']['y']:.4f}", + f"{data['right_hand']['pos']['z']:.4f}", + f"{data['right_hand']['rot']['w']:.4f}", + f"{data['right_hand']['rot']['x']:.4f}", + f"{data['right_hand']['rot']['y']:.4f}", + f"{data['right_hand']['rot']['z']:.4f}" + ] + output.write(";".join(row) + "\n") + + csv_content = output.getvalue() + output.close() + + # Create filename with timestamp + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{tracking_state['group_id']}_tracking_{timestamp}.csv" + + from fastapi.responses import Response + return Response( + content=csv_content, + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + # SSE endpoint @app.get("/news") diff --git a/experiment-scripts/index.html b/experiment-scripts/index.html index 0c9d162..d3896b1 100644 --- a/experiment-scripts/index.html +++ b/experiment-scripts/index.html @@ -5,345 +5,7 @@ VR Charades Experiment Control - - +
@@ -411,6 +73,36 @@
+ +
+

VR Headset Configuration

+ +
+ + +
IP address that VR headsets should connect to
+
+ +
+ + +
Controls which body parts are shown and interaction method
+
+ +
+ +
+ + +
+

Word List

@@ -431,6 +123,10 @@
+ +
+
+ Tracking data is automatically recorded during experiments and includes camera and controller positions/rotations at 60Hz.
@@ -446,319 +142,6 @@ - + - diff --git a/experiment-scripts/script.js b/experiment-scripts/script.js new file mode 100644 index 0000000..ce40a53 --- /dev/null +++ b/experiment-scripts/script.js @@ -0,0 +1,479 @@ +// Global state variables +let runningWordIndex = -1; +let runningWordList = []; +let experimentStartTime = null; +let totalDurationMs = 0; +let frameId = undefined; +let last = undefined; +let newWord; +let lastWordStatus = -1; +let experimentRunning = false; + +// Update button states based on experiment status +function updateButtonStates() { + document.getElementById("btn-stop").disabled = !experimentRunning; + document.getElementById("btn-save").disabled = experimentRunning || runningWordList.length === 0; + document.getElementById("btn-start").disabled = experimentRunning; + document.getElementById("btn-shuffle").disabled = experimentRunning; +} + +// Create word items from word list +function createWordItems() { + const wordList = document.getElementById("word-list"); + const text = wordList.value; + const display = document.getElementById("word-list-display"); + display.innerHTML = ""; + runningWordList = []; + runningWordIndex = -1; + + const words = text.trim().split('\n').filter(w => w.trim()); + + words.forEach((word, index) => { + const div = document.createElement("div"); + div.classList.add("word-item"); + div.setAttribute("data-word", word.trim()); + div.setAttribute("data-index", index); + + const content = document.createElement("div"); + content.classList.add("word-content"); + + const indexSpan = document.createElement("span"); + indexSpan.classList.add("word-index"); + indexSpan.textContent = `${(index + 1).toString().padStart(2, '0')}.`; + + const textSpan = document.createElement("span"); + textSpan.classList.add("word-text"); + textSpan.textContent = word.trim(); + + content.appendChild(indexSpan); + content.appendChild(textSpan); + + const timer = document.createElement("div"); + timer.classList.add("word-timer"); + const timeSeconds = parseFloat(document.getElementById("time-s").value); + timer.textContent = timeSeconds.toFixed(1) + "s"; + timer.setAttribute("data-remaining", timeSeconds); + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.classList.add("word-checkbox"); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + div.classList.add("correct"); + div.classList.remove("incorrect"); + } else { + div.classList.remove("correct"); + } + }); + + div.appendChild(content); + div.appendChild(timer); + div.appendChild(checkbox); + display.appendChild(div); + + runningWordList.push(div); + }); +} + +// Main animation loop +function step(timestamp) { + if (last === undefined) { + last = timestamp; + experimentStartTime = timestamp; + runningWordIndex = 0; + newWord = true; + lastWordStatus = -1; + } + + const elapsed = timestamp - last; + last = timestamp; + + // Update total timer + const timerDisplay = document.getElementById("timer-display"); + if (totalDurationMs > 0) { + const totalElapsed = timestamp - experimentStartTime; + const remainingMs = totalDurationMs - totalElapsed; + + const remainingMinutes = Math.floor(remainingMs / 60000); + const remainingSeconds = Math.floor((remainingMs % 60000) / 1000); + timerDisplay.textContent = + `${remainingMinutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; + + timerDisplay.className = "timer-display"; + if (remainingMs < 60000) { + timerDisplay.classList.add("danger"); + } else if (remainingMs < 120000) { + timerDisplay.classList.add("warning"); + } + + if (totalElapsed >= totalDurationMs) { + stopExperiment(); + alert("Experiment completed: Total duration reached"); + return; + } + } + + if (runningWordIndex >= runningWordList.length) { + stopExperiment(); + return; + } + + const currentWordDiv = runningWordList[runningWordIndex]; + const timer = currentWordDiv.querySelector(".word-timer"); + const checkbox = currentWordDiv.querySelector('input[type="checkbox"]'); + + let remainingTime = parseFloat(timer.getAttribute("data-remaining")) - (elapsed / 1000); + + if (newWord) { + newWord = false; + const word = currentWordDiv.getAttribute("data-word"); + sendNewWord(word, lastWordStatus, remainingTime); + + // Remove active class from all + runningWordList.forEach(div => div.classList.remove("active")); + currentWordDiv.classList.add("active"); + } + + if (checkbox.checked || remainingTime < 0) { + if (remainingTime < 0) remainingTime = 0; + + lastWordStatus = checkbox.checked ? 1 : 0; + timer.textContent = remainingTime.toFixed(1) + "s"; + currentWordDiv.classList.remove("active"); + + if (checkbox.checked) { + currentWordDiv.classList.add("correct"); + } else { + currentWordDiv.classList.add("incorrect"); + } + + runningWordIndex++; + newWord = true; + } else { + timer.textContent = remainingTime.toFixed(1) + "s"; + timer.setAttribute("data-remaining", remainingTime); + } + + if (runningWordIndex < runningWordList.length) { + frameId = requestAnimationFrame(step); + } else { + stopExperiment(); + } +} + +// Stop the experiment +function stopExperiment() { + if (frameId) { + cancelAnimationFrame(frameId); + frameId = undefined; + } + experimentRunning = false; + updateButtonStates(); + + // Stop tracking recording + stopTracking(); + + console.log("Experiment stopped"); +} + +// Start tracking recording +async function startTracking(groupId, condition, activePlayerIp) { + try { + const response = await fetch("/tracking/start", { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + group_id: groupId, + condition: condition, + active_player_ip: activePlayerIp + }), + }); + + const result = await response.json(); + console.log("Tracking started:", result.message); + } catch (error) { + console.error("Error starting tracking:", error); + } +} + +// Stop tracking recording +async function stopTracking() { + try { + const response = await fetch("/tracking/stop", { + method: "POST", + }); + + const result = await response.json(); + console.log("Tracking stopped:", result.message); + } catch (error) { + console.error("Error stopping tracking:", error); + } +} + +// Download tracking data +async function downloadTracking() { + try { + const response = await fetch("/tracking/download"); + + if (response.ok) { + // Get filename from Content-Disposition header + const disposition = response.headers.get('Content-Disposition'); + let filename = 'tracking_data.csv'; + if (disposition && disposition.includes('filename=')) { + filename = disposition.split('filename=')[1].replace(/"/g, ''); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + console.log("Tracking data downloaded"); + } else { + const result = await response.json(); + alert("Error: " + (result.message || "Failed to download tracking data")); + } + } catch (error) { + console.error("Error downloading tracking:", error); + alert("Failed to download tracking data: " + error.message); + } +} + +// Send word to VR headsets +function sendNewWord(word, lastWordStatus, timeSeconds) { + const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1"; + const ipPlayer1 = document.getElementById("ip-player1").value; + const ipPlayer2 = document.getElementById("ip-player2").value; + + const target = isPlayer1 ? ipPlayer1 : ipPlayer2; + const targetOther = isPlayer1 ? ipPlayer2 : ipPlayer1; + + fetch("/word", { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + target: target, + lastWordStatus: lastWordStatus, + timeSeconds: timeSeconds, + word: word, + }), + }); + + fetch("/word", { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + target: targetOther, + lastWordStatus: lastWordStatus, + timeSeconds: 0.0, + word: "", + }), + }); +} + +// Send VR headset configuration (IP and MODE) +async function sendVRConfig() { + const ipPlayer1 = document.getElementById("ip-player1").value; + const ipPlayer2 = document.getElementById("ip-player2").value; + const mode = document.getElementById("experiment-mode").value; + const serverIp = document.getElementById("server-ip").value; + + if (!ipPlayer1 || !ipPlayer2) { + showConfigStatus("Please enter both player IP addresses", "error"); + return; + } + + if (!serverIp) { + showConfigStatus("Please enter the server IP address", "error"); + return; + } + + try { + const response = await fetch("/send-config", { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player1_ip: ipPlayer1, + player2_ip: ipPlayer2, + server_ip: serverIp, + mode: mode, + }), + }); + + const result = await response.json(); + if (result.status === "ok") { + showConfigStatus(`Configuration sent successfully! Mode: ${getModeDescription(mode)}`, "success"); + } else { + showConfigStatus("Failed to send configuration", "error"); + } + } catch (error) { + console.error("Error sending configuration:", error); + showConfigStatus("Error sending configuration: " + error.message, "error"); + } +} + +// Get human-readable mode description +function getModeDescription(mode) { + const modes = { + "1;1;1;0": "Dynamic Face", + "0;0;0;1": "Dynamic Hands", + "1;1;1;1": "Dynamic Hands+Face", + "1;0;0;0": "Static Face", + "0;0;0;1": "Static Hands", + "1;0;0;1": "Static Hands+Face" + }; + return modes[mode] || mode; +} + +// Show configuration status message +function showConfigStatus(message, type) { + const statusDiv = document.getElementById("config-status"); + statusDiv.textContent = message; + statusDiv.className = `status-message ${type}`; + statusDiv.style.display = "block"; + + // Auto-hide after 5 seconds + setTimeout(() => { + statusDiv.style.display = "none"; + }, 5000); +} + +// Event Listeners +document.addEventListener("DOMContentLoaded", () => { + // Start Experiment + document.getElementById("btn-start").addEventListener("click", () => { + const groupId = document.getElementById("group-id").value; + + if (!groupId || !groupId.trim()) { + alert("Please enter a Group ID before starting."); + return; + } + + const wordList = document.getElementById("word-list").value; + if (!wordList.trim()) { + alert("Please enter a word list before starting."); + return; + } + + const totalDurationMin = parseFloat(document.getElementById("total-duration").value) || 0; + totalDurationMs = totalDurationMin * 60 * 1000; + + const timerDisplay = document.getElementById("timer-display"); + if (totalDurationMs > 0) { + const minutes = Math.floor(totalDurationMs / 60000); + const seconds = Math.floor((totalDurationMs % 60000) / 1000); + timerDisplay.textContent = + `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + timerDisplay.className = "timer-display"; + } else { + timerDisplay.textContent = "∞"; + timerDisplay.className = "timer-display"; + } + + createWordItems(); + if (frameId) cancelAnimationFrame(frameId); + + last = undefined; + newWord = undefined; + experimentStartTime = null; + experimentRunning = true; + updateButtonStates(); + + // Start tracking recording + const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1"; + const ipPlayer1 = document.getElementById("ip-player1").value; + const ipPlayer2 = document.getElementById("ip-player2").value; + const activePlayerIp = isPlayer1 ? ipPlayer1 : ipPlayer2; + const condition = document.getElementById("experiment-mode").value; + const conditionName = getModeDescription(condition); + + startTracking(groupId, conditionName, activePlayerIp); + + frameId = requestAnimationFrame(step); + + console.log(`Experiment started: Group=${groupId}, Duration=${totalDurationMin}min, Condition=${conditionName}`); + }); + + // Stop Experiment + document.getElementById("btn-stop").addEventListener("click", stopExperiment); + + // Save Results + document.getElementById("btn-save").addEventListener("click", () => { + if (runningWordList.length === 0) { + alert("No data to save. Please run the experiment first."); + return; + } + + const groupId = document.getElementById("group-id").value || "group"; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + + let data = "word;correct;time_left\n"; + const maxIndex = runningWordIndex >= runningWordList.length ? runningWordList.length : runningWordIndex; + + for (let i = 0; i < maxIndex; i++) { + const div = runningWordList[i]; + const word = div.getAttribute("data-word"); + const checkbox = div.querySelector('input[type="checkbox"]'); + const timer = div.querySelector(".word-timer"); + const isCorrect = checkbox.checked; + const timeLeft = parseFloat(timer.getAttribute("data-remaining")); + + data += `${word};${isCorrect};${timeLeft}\n`; + } + + const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.href = url; + link.download = `${groupId}_results_${timestamp}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + console.log(`Saved ${maxIndex} out of ${runningWordList.length} words to CSV`); + }); + + // Shuffle Words + document.getElementById("btn-shuffle").addEventListener("click", async () => { + const wordList = document.getElementById("word-list"); + const text = wordList.value.trim(); + + if (!text) { + alert("Please enter words to shuffle first."); + return; + } + + const words = text.split('\n').map(word => word.trim()).filter(word => word); + + try { + const response = await fetch("/shuffle", { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ words: words }), + }); + + const result = await response.json(); + if (result.status === "ok") { + wordList.value = result.shuffled_words.join('\n'); + } + } catch (error) { + console.error("Error shuffling words:", error); + alert("Failed to shuffle words. Please try again."); + } + }); + + // Send VR Configuration + document.getElementById("btn-send-config").addEventListener("click", sendVRConfig); + + // Download Tracking Data + document.getElementById("btn-download-tracking").addEventListener("click", downloadTracking); + + // Initialize button states + updateButtonStates(); +}); + diff --git a/experiment-scripts/server.py b/experiment-scripts/server.py index 8d00925..7303115 100644 --- a/experiment-scripts/server.py +++ b/experiment-scripts/server.py @@ -30,7 +30,16 @@ def forward(source_socket): label = "A→B" if addr == DEVICE1_ADDR else "B→A" if addr != DEVICE1_ADDR and addr != DEVICE2_ADDR: label = f"unknown {addr}" + + # Forward to other player sock_from_A.sendto(data, (target_ip, 5000)) + + # Also forward to app.py tracking listener on port 5002 + # Prepend source IP so app.py can identify which player sent the data + source_ip = addr[0] + tagged_data = f"SOURCE_IP:{source_ip}|".encode('utf-8') + data + sock_from_A.sendto(tagged_data, ("127.0.0.1", 5002)) + timestamp = datetime.now().strftime("%H:%M:%S") # Logging diff --git a/experiment-scripts/styles.css b/experiment-scripts/styles.css new file mode 100644 index 0000000..589a272 --- /dev/null +++ b/experiment-scripts/styles.css @@ -0,0 +1,365 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', sans-serif; + background: #f5f5f5; + color: #2c3e50; + line-height: 1.5; +} + +.header { + background: #2c3e50; + color: white; + padding: 1.5rem 2rem; + border-bottom: 3px solid #34495e; +} + +.header h1 { + font-size: 1.5rem; + font-weight: 600; +} + +.header p { + font-size: 0.9rem; + color: #bdc3c7; + margin-top: 0.25rem; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +.timer-section { + background: white; + border: 2px solid #34495e; + border-radius: 4px; + padding: 2rem; + margin-bottom: 2rem; + text-align: center; +} + +.timer-label { + font-size: 0.875rem; + color: #7f8c8d; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.5rem; +} + +.timer-display { + font-family: 'Roboto Mono', monospace; + font-size: 4rem; + font-weight: 500; + color: #2c3e50; +} + +.timer-display.warning { + color: #e67e22; +} + +.timer-display.danger { + color: #e74c3c; +} + +.timer-display.inactive { + color: #bdc3c7; +} + +.main-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +.section { + background: white; + border: 1px solid #ddd; + border-radius: 4px; + padding: 1.5rem; +} + +.section-title { + font-size: 1rem; + font-weight: 600; + color: #2c3e50; + margin-bottom: 1.5rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid #ecf0f1; +} + +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #34495e; + margin-bottom: 0.5rem; +} + +.form-input { + width: 100%; + padding: 0.625rem 0.75rem; + border: 1px solid #cbd5e0; + border-radius: 3px; + font-size: 0.9375rem; + font-family: inherit; + transition: border-color 0.15s; +} + +.form-input:focus { + outline: none; + border-color: #3498db; +} + +.form-input:disabled { + background: #f8f9fa; + color: #adb5bd; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.radio-group { + display: flex; + gap: 1.5rem; + margin-top: 0.5rem; +} + +.radio-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.radio-label input { + cursor: pointer; +} + +.btn { + padding: 0.625rem 1.25rem; + border: none; + border-radius: 3px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: #3498db; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #2980b9; +} + +.btn-success { + background: #27ae60; + color: white; +} + +.btn-success:hover:not(:disabled) { + background: #229954; +} + +.btn-danger { + background: #e74c3c; + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #c0392b; +} + +.btn-secondary { + background: #95a5a6; + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background: #7f8c8d; +} + +.btn-warning { + background: #f39c12; + color: white; +} + +.btn-warning:hover:not(:disabled) { + background: #e67e22; +} + +.btn-group { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +.controls-section { + background: white; + border: 1px solid #ddd; + border-radius: 4px; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.controls-section .btn-group { + margin-top: 0; +} + +textarea { + font-family: 'Roboto Mono', monospace; + font-size: 0.875rem; + line-height: 1.5; + resize: vertical; +} + +.word-display { + background: white; + border: 1px solid #ddd; + border-radius: 4px; + padding: 1.5rem; +} + +.word-list-container { + max-height: 500px; + overflow-y: auto; + margin-top: 1rem; +} + +.word-item { + background: #fafafa; + border: 1px solid #e0e0e0; + border-radius: 3px; + padding: 0.75rem 1rem; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + justify-content: space-between; + transition: background 0.15s; +} + +.word-item:hover { + background: #f0f0f0; +} + +.word-item.active { + background: #e3f2fd; + border-color: #3498db; +} + +.word-item.correct { + background: #e8f5e9; + border-color: #27ae60; +} + +.word-item.incorrect { + background: #ffebee; + border-color: #e74c3c; +} + +.word-content { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; +} + +.word-index { + font-family: 'Roboto Mono', monospace; + font-size: 0.8125rem; + color: #7f8c8d; + min-width: 2.5rem; +} + +.word-text { + font-size: 0.9375rem; + color: #2c3e50; +} + +.word-timer { + font-family: 'Roboto Mono', monospace; + font-size: 1rem; + color: #34495e; + min-width: 4rem; + text-align: right; +} + +.word-checkbox { + margin-left: 1rem; +} + +.placeholder { + text-align: center; + padding: 3rem 1rem; + color: #95a5a6; +} + +.small-text { + font-size: 0.8125rem; + color: #7f8c8d; + margin-top: 0.25rem; +} + +.status-message { + padding: 0.75rem 1rem; + border-radius: 3px; + margin-top: 1rem; + font-size: 0.875rem; +} + +.status-message.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.status-message.error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +@media (max-width: 768px) { + .main-grid { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .timer-display { + font-size: 3rem; + } + + .btn-group { + flex-direction: column; + } + + .btn { + width: 100%; + } +} +