| # π‘οΈ Sentinel β Deep Implementation Playbook |
| ## 10 Upgrades: Fully Architected, Battle-Planned |
|
|
| **Rule:** Agent does all the thinking. You review the plan, approve or redirect, then agent executes. |
| **Rule:** Visual/UI work requires you to approve a reference style before agent writes any code. |
|
|
| --- |
|
|
| ## UPGRADE 1: Client-Side Frame Differencing (OpenCV.js) |
|
|
| **Goal:** Browser analyzes frames locally, only uploads to server when motion detected. |
| **Files touched:** `sensor_bridge.py` only. |
| **New lines:** ~80 lines of JavaScript. |
|
|
| ### Architecture |
|
|
| ``` |
| CURRENT FLOW (wasteful): |
| Camera β canvas 320x240 β base64 JPEG β image-data textbox β server process_frame |
| Rate: 2 FPS always = 120 uploads/minute |
| |
| NEW FLOW (smart): |
| Camera β canvas 320x240 β OpenCV.js cv.absdiff(prev, current) β |
| if changed_pixels > threshold β upload base64 to server |
| if not β skip upload, increment local counter |
| Rate: ~0.3-0.8 FPS average = 18-48 uploads/minute |
| ``` |
|
|
| ### Implementation Plan |
|
|
| **Step 1: Add OpenCV.js CDN load to sensor_bridge.py** |
| |
| Insert at the top of the HTML string (before the IIFE): |
| ```html |
| <script async src="https://docs.opencv.org/4.x/opencv.js" |
| onload="window.__cvReady = true;" type="text/javascript"></script> |
| ``` |
| |
| **Step 2: Add state variables inside the IIFE** |
| |
| ```javascript |
| let prevMat = null; // Previous frame as cv.Mat |
| let cvReady = false; // OpenCV.js loaded flag |
| let skippedFrames = 0; // Count of locally-filtered frames |
| const DIFF_THRESHOLD = 0.12; // 12% pixel change = significant |
| ``` |
| |
| **Step 3: Modify `captureFrame()` function** |
|
|
| The current `captureFrame()` always uploads. New version: |
|
|
| ``` |
| captureFrame(): |
| 1. Draw video to canvas (same as now) |
| 2. If cvReady: |
| a. Read canvas into cv.Mat (srcMat) |
| b. Convert to grayscale (cv.cvtColor) |
| c. If prevMat exists: |
| - cv.absdiff(prevMat, srcMat, diffMat) |
| - cv.threshold(diffMat, threshMat, 25, 255, cv.THRESH_BINARY) |
| - changedPct = cv.countNonZero(threshMat) / (width * height) |
| - If changedPct < DIFF_THRESHOLD: |
| skippedFrames++ |
| update UI indicator ("Filtered locally: X frames") |
| cleanup Mats, return (DON'T upload) |
| d. prevMat = srcMat.clone() |
| e. cleanup temp Mats |
| 3. Proceed with base64 upload (same as now) |
| ``` |
|
|
| **Step 4: Add skipped frame counter to UI** |
|
|
| Add a hidden Gradio Number component: |
| ```python |
| # In app.py Monitor tab, alongside other hidden numbers: |
| skipped_frames = gr.Number(value=0, visible=False, elem_id="skipped-frames") |
| ``` |
|
|
| JS updates it via `setGradioValue("skipped-frames", skippedFrames)` every 2 seconds. |
|
|
| ### Edge Cases |
| - **OpenCV.js fails to load (CDN blocked):** Fall back to current behavior (always upload). Check `window.__cvReady` before using cv. |
| - **First frame:** No prevMat exists β always upload first frame, store as prevMat. |
| - **Mat memory leak:** Always call `.delete()` on temporary Mats. Only keep prevMat alive. |
| - **Camera off:** Reset prevMat to null when camera stops. |
|
|
| ### Why This Wins |
| The 240x cost story goes from "trust our architecture" to "watch the counter β 847 frames processed locally, only 4 sent to GPU." That's a demo moment. |
|
|
| ### Testing Sequence |
| 1. Deploy with upgrade β activate β wave hand in front of camera β see uploads spike |
| 2. Sit still β see uploads drop to near zero |
| 3. Check that simulation mode still works (it bypasses camera entirely) |
|
|
| --- |
|
|
| ## UPGRADE 2: Three.js / CSS3D Gyroscope HUD |
|
|
| **Goal:** Floating glass HUD overlay on camera feed. Tilts with phone. Shows live telemetry. |
| **Files touched:** `sensor_bridge.py` (~200 lines JS/CSS). |
| **Dependency:** None (pure client-side). |
|
|
| ### Architecture Decision |
|
|
| **Option A β Pure CSS (Recommended for reliability):** |
| - No external library |
| - CSS `transform: perspective(1000px) rotateX() rotateY()` driven by `deviceorientation` |
| - ~80 lines, impossible to break |
| - Looks great but not "3D" |
|
|
| **Option B β Three.js CSS3DRenderer (Recommended for wow):** |
| - Load three.js from CDN (~150KB) |
| - Create CSS3DObject from HUD HTML elements |
| - Rotate entire CSS3D scene based on device orientation |
| - ~200 lines, can break if CDN fails |
| - True 3D parallax effect |
|
|
| ### My Recommendation: Option A for reliability, Option B for demo video |
|
|
| Start with Option A. It works on every device, including iOS. If you have time, add Option B as an enhancement. |
|
|
| ### Implementation Plan (Option A β Pure CSS) |
|
|
| **Step 1: Add HUD container HTML to sensor_bridge.py** |
| |
| ```html |
| <div id="sentinel-hud" style=" |
| position: fixed; |
| top: 80px; |
| left: 50%; |
| transform: translateX(-50%); |
| z-index: 1000; |
| pointer-events: none; |
| transition: transform 0.1s ease-out; |
| perspective: 800px; |
| display: none; |
| "> |
| <div id="hud-inner" style=" |
| background: rgba(255,255,255,0.06); |
| border: 1px solid rgba(255,255,255,0.15); |
| border-radius: 16px; |
| padding: 12px 20px; |
| backdrop-filter: blur(20px); |
| min-width: 240px; |
| font-family: monospace; |
| color: #a5b4fc; |
| "> |
| <div id="hud-compass">Heading: 0Β° N</div> |
| <div id="hud-threat" style="color: #4ade80;">Threat: CLEAR</div> |
| <div id="hud-fps">FPS: 2.0 | Filtered: 0</div> |
| <div id="hud-battery">Battery: 100%</div> |
| </div> |
| </div> |
| ``` |
| |
| **Step 2: Add deviceorientation handler** |
| |
| ```javascript |
| // Inside IIFE, after existing sensor handlers: |
| let hudActive = false; |
| const hudEl = document.getElementById("sentinel-hud"); |
| const hudInner = document.getElementById("hud-inner"); |
| |
| function handleHUDOrientation(event) { |
| if (!hudActive || !hudInner) return; |
| // beta = front-back tilt (-180 to 180) |
| // gamma = left-right tilt (-90 to 90) |
| const tiltX = Math.max(-8, Math.min(8, event.gamma * 0.15)); |
| const tiltY = Math.max(-8, Math.min(8, (event.beta - 45) * 0.15)); |
| hudInner.style.transform = |
| `perspective(800px) rotateX(${-tiltY}deg) rotateY(${tiltX}deg)`; |
| } |
| window.addEventListener('deviceorientation', handleHUDOrientation); |
| ``` |
| |
| **Step 3: Update HUD data on each sensor tick** |
| |
| The existing sensor handlers already have access to heading, battery, etc. Add DOM updates: |
| |
| ```javascript |
| // In the existing telemetry update cycle: |
| if (hudActive) { |
| document.getElementById("hud-compass").textContent = |
| `Heading: ${heading}Β° ${getCompassDir(heading)}`; |
| document.getElementById("hud-battery").textContent = |
| `Battery: ${batteryPct}%`; |
| document.getElementById("hud-threat").textContent = |
| `Threat: ${currentThreatLevel}`; |
| document.getElementById("hud-threat").style.color = |
| currentThreatLevel === "CLEAR" ? "#4ade80" : |
| currentThreatLevel === "WARNING" ? "#fbbf24" : "#ef4444"; |
| } |
| ``` |
| |
| **Step 4: Show HUD on activation** |
| |
| In `bindActivationButton()`, after starting sensors: |
| ```javascript |
| if (hudEl) { hudEl.style.display = "block"; hudActive = true; } |
| ``` |
| |
| ### What You Need to Approve |
| - HUD position: top-center? bottom? full-width strip? |
| - Data fields: compass + threat + FPS + battery? Or different data? |
| - Glass style: subtle (0.06 opacity) or more visible (0.12)? |
| - Color scheme: indigo text (#a5b4fc) or white? |
| |
| ### Testing Sequence |
| 1. Activate on phone β HUD appears floating over camera |
| 2. Tilt phone left β HUD tilts right (parallax) |
| 3. Trigger simulation β HUD threat level changes color |
| 4. Test on iOS β works because it's pure CSS, no WebGL |
| |
| --- |
| |
| ## UPGRADE 3: Binaural 3D Spatial Audio (Web Audio API) |
| |
| **Goal:** Alert voice sounds like it's coming FROM the direction of danger. |
| **Files touched:** `sensor_bridge.py` (~100 lines JS), `app.py` (~10 lines modified). |
| **Dependency:** None (Web Audio API is native to all modern browsers). |
|
|
| ### Architecture |
|
|
| ``` |
| CURRENT: |
| process_frame β alert_html with <audio autoplay src="data:audio/wav;base64,..."> |
| β browser plays audio from center (no spatial) |
| |
| NEW: |
| process_frame β determines threat_x, threat_y from detection bbox center |
| β embeds them as data attributes in alert_html |
| β JS intercepts: decodes base64 audio β creates AudioBufferSourceNode |
| β connects to PannerNode (HRTF model, positioned at threat_x/y/z) |
| β connects to AudioListener (oriented by deviceorientation) |
| β audio plays from spatial position |
| ``` |
|
|
| ### Implementation Plan |
|
|
| **Step 1: Add spatial audio engine to sensor_bridge.py** |
| |
| ```javascript |
| // Inside IIFE: |
| let spatialAudioCtx = null; |
| let audioListener = null; |
| |
| function initSpatialAudio() { |
| spatialAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); |
| audioListener = spatialAudioCtx.listener; |
| // Set listener to face forward |
| if (audioListener.positionX) { |
| audioListener.positionX.value = 0; |
| audioListener.positionY.value = 0; |
| audioListener.positionZ.value = 0; |
| audioListener.forwardX.value = 0; |
| audioListener.forwardY.value = 0; |
| audioListener.forwardZ.value = -1; |
| } |
| } |
| |
| async function playSpatialAudio(base64Audio, threatX, threatY) { |
| if (!spatialAudioCtx) initSpatialAudio(); |
| if (spatialAudioCtx.state === 'suspended') await spatialAudioCtx.resume(); |
| |
| // Decode base64 WAV to AudioBuffer |
| const binary = atob(base64Audio); |
| const bytes = new Uint8Array(binary.length); |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); |
| const audioBuffer = await spatialAudioCtx.decodeAudioData(bytes.buffer); |
| |
| // Create source |
| const source = spatialAudioCtx.createBufferSource(); |
| source.buffer = audioBuffer; |
| |
| // Create 3D panner |
| const panner = spatialAudioCtx.createPanner(); |
| panner.panningModel = 'HRTF'; |
| panner.distanceModel = 'inverse'; |
| panner.refDistance = 1; |
| panner.maxDistance = 10; |
| // Position: threatX maps to left(-1) to right(+1), Y to up/down |
| panner.positionX.value = threatX * 2; // -2 to +2 meters |
| panner.positionY.value = threatY; |
| panner.positionZ.value = -2; // 2 meters in front |
| |
| // Connect: source β panner β destination |
| source.connect(panner); |
| panner.connect(spatialAudioCtx.destination); |
| source.start(0); |
| } |
| ``` |
| |
| **Step 2: Update deviceorientation to rotate listener** |
| |
| ```javascript |
| // In existing handleOrientation function, add: |
| if (spatialAudioCtx && audioListener) { |
| const rad = (event.alpha || 0) * Math.PI / 180; |
| if (audioListener.forwardX) { |
| audioListener.forwardX.value = -Math.sin(rad); |
| audioListener.forwardZ.value = -Math.cos(rad); |
| } |
| } |
| ``` |
| |
| **Step 3: Modify app.py alert generation** |
| |
| In `process_frame`, when building alert_html, embed threat coordinates: |
| |
| ```python |
| # After YOLO detections, calculate threat position |
| threat_x = 0.0 # center by default |
| threat_y = 0.0 |
| if detections: |
| # Use center of first trigger detection bbox, normalized to -1..1 |
| det = detections[0] |
| threat_x = ((det.bbox[0] + det.bbox[2]) / 2 / 640 - 0.5) * 2 # -1 to 1 |
| threat_y = -((det.bbox[1] + det.bbox[3]) / 2 / 480 - 0.5) * 2 # inverted Y |
| |
| # In alert_html, add data attributes: |
| alert_html = f"""<div class='alert-banner-{alert_level}' |
| data-threat-x='{threat_x:.2f}' |
| data-threat-y='{threat_y:.2f}' |
| data-audio-src='{audio_data_uri}'> |
| β οΈ {alert_text}{alert_image_html}</div>""" |
| # Remove the <audio autoplay> tag β JS handles playback now |
| ``` |
| |
| **Step 4: JS observer detects new alert and triggers spatial playback** |
|
|
| ```javascript |
| // MutationObserver on alert-banner-container |
| const alertObserver = new MutationObserver((mutations) => { |
| for (const m of mutations) { |
| const banner = m.target.querySelector('[data-audio-src]'); |
| if (banner) { |
| const audioSrc = banner.dataset.audioSrc; |
| const tx = parseFloat(banner.dataset.threatX || 0); |
| const ty = parseFloat(banner.dataset.threatY || 0); |
| if (audioSrc && audioSrc.startsWith('data:audio')) { |
| const base64 = audioSrc.split(',')[1]; |
| playSpatialAudio(base64, tx, ty); |
| } |
| } |
| } |
| }); |
| ``` |
|
|
| ### What You Need to Approve |
| - Fallback: if Web Audio fails (old browser), fall back to `<audio autoplay>` tag? |
| - Spatial range: Β±2 meters (subtle) or Β±5 meters (dramatic)? |
| - Should listener rotate with gyroscope or stay fixed forward? |
|
|
| ### Testing Sequence |
| 1. Put on headphones |
| 2. Trigger "Person Approaching" sim (person comes from left) |
| 3. Voice should sound like it's coming from the LEFT earphone |
| 4. Turn phone 90Β° β voice position shifts relative to your head |
| 5. Test without headphones β still works but less immersive |
|
|
| --- |
|
|
| ## UPGRADE 4: Dynamic SVG Bounding Box Overlays |
|
|
| **Goal:** YOLO detection boxes drawn as animated SVG overlays on the camera feed. |
| **Files touched:** `sensor_bridge.py` (~50 lines JS), `app.py` (~30 lines). |
| **Dependency:** Upgrade 1 (client-side frame diff) recommended first. |
|
|
| ### Architecture |
|
|
| ``` |
| CURRENT: |
| process_frame β YOLO detect β detections used internally only β discarded |
| Camera feed shows raw video with no annotations |
| |
| NEW: |
| process_frame β YOLO detect β serialize detections as JSON β |
| write to hidden textbox "detection-data" β |
| JS reads JSON β creates/updates SVG overlay on camera feed β |
| animated rectangles with class labels |
| ``` |
|
|
| ### Implementation Plan |
|
|
| **Step 1: Always run YOLO (not just when gatekeeper triggers)** |
|
|
| In `app.py`, change: |
| ```python |
| # BEFORE (line 563): |
| if change_result.is_significant and object_detector is not None: |
| |
| # AFTER: |
| if object_detector is not None: |
| ``` |
|
|
| This means YOLO runs on every frame, not just significant changes. |
| Cost: ~5ms per frame on CPU. Acceptable for real-time display. |
| The GATEKEEPER still decides whether to call VLM β this only changes what we SHOW. |
|
|
| **Step 2: Serialize detections to JSON** |
|
|
| After YOLO runs, write detection data: |
| ```python |
| # After line 565 (yolo_summary = ...): |
| detection_json = json.dumps([ |
| { |
| "class": d.class_name, |
| "conf": round(d.confidence, 2), |
| "bbox": list(d.bbox), |
| "trigger": d.is_trigger |
| } |
| for d in detections |
| ]) |
| ``` |
|
|
| **Step 3: Add hidden textbox for detection data** |
|
|
| In app.py Monitor tab: |
| ```python |
| detection_data = gr.Textbox(value="[]", visible=False, elem_id="detection-data") |
| ``` |
|
|
| Update it in process_frame return tuple (add to outputs). |
| |
| **Step 4: SVG overlay in sensor_bridge.py** |
| |
| ```javascript |
| // Create SVG overlay positioned over the camera feed image |
| function createSVGOverlay() { |
| const cameraImg = document.querySelector('#alert-banner-container') |
| ?.closest('.glass-panel') |
| ?.querySelector('img'); // The camera feed <img> element |
| if (!cameraImg) return null; |
| |
| const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); |
| svg.id = "detection-svg"; |
| svg.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10;"; |
| cameraImg.parentElement.style.position = "relative"; |
| cameraImg.parentElement.appendChild(svg); |
| return svg; |
| } |
| |
| // Color map for detection classes |
| const CLASS_COLORS = { |
| "person": "#3b82f6", |
| "vehicle": "#ef4444", |
| "fire": "#f97316", |
| "animal": "#a855f7", |
| "default": "#22c55e" |
| }; |
| |
| function drawDetections(detections) { |
| const svg = document.getElementById("detection-svg") || createSVGOverlay(); |
| if (!svg) return; |
| svg.innerHTML = ""; // Clear previous |
| |
| detections.forEach(det => { |
| const [x1, y1, x2, y2] = det.bbox; |
| const color = CLASS_COLORS[det.class] || CLASS_COLORS.default; |
| const w = x2 - x1, h = y2 - y1; |
| |
| // Animated rectangle |
| const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); |
| rect.setAttribute("x", x1); rect.setAttribute("y", y1); |
| rect.setAttribute("width", w); rect.setAttribute("height", h); |
| rect.setAttribute("fill", "none"); |
| rect.setAttribute("stroke", color); |
| rect.setAttribute("stroke-width", "3"); |
| rect.setAttribute("rx", "4"); |
| rect.style.opacity = "0"; |
| rect.style.transition = "opacity 0.3s"; |
| svg.appendChild(rect); |
| requestAnimationFrame(() => rect.style.opacity = "0.8"); |
| |
| // Label |
| const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); |
| text.setAttribute("x", x1); text.setAttribute("y", y1 - 6); |
| text.setAttribute("fill", color); |
| text.setAttribute("font-size", "14"); |
| text.setAttribute("font-family", "monospace"); |
| text.textContent = `${det.class} ${(det.conf * 100).toFixed(0)}%`; |
| svg.appendChild(text); |
| }); |
| } |
| ``` |
| |
| **Step 5: Observe detection-data changes** |
|
|
| ```javascript |
| // MutationObserver on hidden detection-data textbox |
| const detObserver = new MutationObserver(() => { |
| const el = document.querySelector('#detection-data input, #detection-data textarea'); |
| if (!el) return; |
| try { |
| const dets = JSON.parse(el.value || "[]"); |
| drawDetections(dets); |
| } catch(e) {} |
| }); |
| ``` |
|
|
| ### What You Need to Approve |
| - Box style: full rectangle? corner brackets? with fill? |
| - Corner brackets look more "professional AI" (think Tesla vision display) |
| - Colors: class-based (above) or all same color? |
| - Always show boxes or only on trigger detections? |
|
|
| ### Testing Sequence |
| 1. Activate β point camera at a person β blue box appears with "person 87%" |
| 2. Point at a car β red box "vehicle 92%" |
| 3. Empty room β no boxes |
| 4. Verify boxes scale correctly when window is resized |
|
|
| --- |
|
|
| ## UPGRADE 5: Kokoro TTS Streaming |
|
|
| **Goal:** First word of alert audio plays within 300ms instead of waiting 2+ seconds for full generation. |
| **Files touched:** `kokoro_tts.py` (~40 lines), `app.py` (~20 lines). |
| **Dependency:** None. |
|
|
| ### Architecture |
|
|
| ``` |
| CURRENT: |
| speaker.speak(full_text) β Kokoro generates complete WAV β base64 encode β return |
| Latency: 1.5-3.0 seconds for a 2-sentence alert |
| |
| NEW: |
| Split text into sentences β generate sentence 1 audio β yield immediately |
| β generate sentence 2 audio β yield |
| Client receives sentence 1 audio at ~300ms, starts playing |
| Sentence 2 arrives while sentence 1 is still playing |
| ``` |
|
|
| ### Implementation Plan |
|
|
| **Step 1: Add streaming method to AlertSpeaker** |
|
|
| In `kokoro_tts.py`, add a new method: |
|
|
| ```python |
| async def speak_streaming(self, text: str, level: str = "warning"): |
| """ |
| Generator that yields audio chunks sentence by sentence. |
| Each chunk is a base64-encoded WAV. |
| """ |
| import re |
| sentences = re.split(r'(?<=[.!?])\s+', text.strip()) |
| |
| for sentence in sentences: |
| if not sentence.strip(): |
| continue |
| try: |
| result = await self.speak(sentence, level=level) |
| if result.get("audio_base64"): |
| yield result["audio_base64"] |
| except Exception as e: |
| logger.error("TTS streaming chunk failed", error=str(e)) |
| break |
| ``` |
|
|
| **Step 2: Modify app.py to use streaming** |
|
|
| Instead of generating full audio then embedding in HTML, use Gradio's streaming audio: |
|
|
| ```python |
| # Replace the current TTS section in process_frame with: |
| audio_chunks = [] |
| if alert_level in ["critical", "warning"]: |
| async for chunk_b64 in speaker.speak_streaming(alert_text, level=alert_level): |
| audio_chunks.append(chunk_b64) |
| |
| # Use first chunk for immediate playback, concatenate for archive |
| if audio_chunks: |
| audio_data_uri = f"data:audio/wav;base64,{audio_chunks[0]}" |
| # Pass remaining chunks via hidden state for sequential playback |
| ``` |
|
|
| **Step 3: Add sequential audio playback in JS** |
|
|
| In sensor_bridge.py: |
| ```javascript |
| // Queue-based audio player that chains chunks |
| let audioQueue = []; |
| let isPlaying = false; |
| |
| function queueAudio(base64Chunks) { |
| audioQueue = [...base64Chunks]; |
| if (!isPlaying) playNext(); |
| } |
| |
| async function playNext() { |
| if (audioQueue.length === 0) { isPlaying = false; return; } |
| isPlaying = true; |
| const chunk = audioQueue.shift(); |
| const audio = new Audio(`data:audio/wav;base64,${chunk}`); |
| audio.onended = () => playNext(); |
| await audio.play(); |
| } |
| ``` |
| |
| ### Edge Cases |
| - **Single sentence alerts:** Works normally, one chunk, no difference from current. |
| - **Kokoro fails on sentence 2:** Graceful stop β sentence 1 already played. |
| - **User triggers new alert while audio playing:** Clear queue, start new alert audio. |
| |
| ### Testing Sequence |
| 1. Trigger "Fall Detected" scenario (multi-sentence alert) |
| 2. Note: first word plays within ~300ms |
| 3. Second sentence plays seamlessly after first |
| 4. Trigger another alert mid-playback β old audio stops, new starts |
| |
| --- |
| |
| ## UPGRADE 6: IMU-Linked Glassmorphic UI |
| |
| **Goal:** Glass panels shift highlights and shadows based on phone tilt. Feels like real glass. |
| **Files touched:** `sensor_bridge.py` (~40 lines JS), `app.py` CUSTOM_CSS (~15 lines). |
| **Dependency:** None. This is the simplest visual upgrade. |
| |
| ### Architecture |
| |
| ``` |
| deviceorientation event (60 FPS) β |
| CSS custom properties on document.documentElement: |
| --tilt-x: gamma * 0.3 (left-right) |
| --tilt-y: beta * 0.3 (front-back) |
| |
| CSS uses these properties: |
| .glass-panel::before { |
| background: radial-gradient( |
| circle at calc(50% + var(--tilt-x) * 1px) calc(50% + var(--tilt-y) * 1px), |
| rgba(255,255,255,0.08) 0%, |
| transparent 60% |
| ); |
| } |
| .glass-panel { |
| box-shadow: |
| calc(var(--tilt-x) * -0.5px) calc(var(--tilt-y) * -0.5px) 20px rgba(0,0,0,0.3); |
| } |
| ``` |
| |
| ### Implementation Plan |
| |
| **Step 1: Add CSS custom properties to CUSTOM_CSS in app.py** |
| |
| ```css |
| :root { |
| --tilt-x: 0; |
| --tilt-y: 0; |
| } |
| .glass-panel { |
| position: relative; |
| overflow: hidden; |
| transition: box-shadow 0.15s ease-out; |
| box-shadow: |
| calc(var(--tilt-x) * -0.5px) |
| calc(var(--tilt-y) * -0.5px) |
| 24px rgba(0,0,0,0.35); |
| } |
| .glass-panel::before { |
| content: ''; |
| position: absolute; |
| top: 0; left: 0; right: 0; bottom: 0; |
| background: radial-gradient( |
| circle at calc(50% + var(--tilt-x) * 2px) calc(30% + var(--tilt-y) * 2px), |
| rgba(255,255,255,0.07) 0%, |
| transparent 50% |
| ); |
| pointer-events: none; |
| z-index: 1; |
| transition: background 0.15s ease-out; |
| } |
| ``` |
| |
| **Step 2: Add JS tilt handler to sensor_bridge.py** |
| |
| ```javascript |
| // Inside IIFE, add alongside existing deviceorientation handler: |
| const MAX_TILT = 25; // cap at Β±25px shift |
| function handleGlassTilt(event) { |
| const gamma = Math.max(-30, Math.min(30, event.gamma || 0)); |
| const beta = Math.max(-30, Math.min(30, (event.beta || 45) - 45)); |
| document.documentElement.style.setProperty('--tilt-x', gamma * 0.8); |
| document.documentElement.style.setProperty('--tilt-y', beta * 0.8); |
| } |
| window.addEventListener('deviceorientation', handleGlassTilt); |
| ``` |
| |
| ### What You Need to Approve |
| - Tilt sensitivity: 0.8 multiplier (moderate)? 0.3 (subtle)? 1.5 (dramatic)? |
| - Highlight intensity: 0.07 opacity (shown above)? Higher = more visible but less classy |
| - Desktop fallback: track mouse position instead of gyroscope? Or no effect on desktop? |
|
|
| ### Testing Sequence |
| 1. Open on phone β panels look normal |
| 2. Tilt phone left β highlight slides right, shadow shifts left |
| 3. Tilt forward β highlight moves up, shadow moves down |
| 4. Looks like a physical piece of glass catching light |
| 5. On desktop with no gyro β either static or mouse-follows |
|
|
| --- |
|
|
| ## UPGRADE 7: Live Cost Race Ticker |
|
|
| **Goal:** Split-screen widget showing Sentinel vs GPT-4o costs racing in real-time. |
| **Files touched:** `sensor_bridge.py` (~100 lines JS/HTML), `app.py` (~20 lines). |
| **Dependency:** None. |
|
|
| ### Architecture |
|
|
| ``` |
| JS reads cost_tracker data from hidden Gradio components: |
| - cost-total (existing) β Sentinel's actual cost |
| - frames-processed (calculate from frame_count_state) |
| |
| Calculates naive GPT-4o cost: |
| frames_processed * $0.01 per frame (GPT-4o Vision at 2 FPS) |
| |
| Displays two animated counters: |
| LEFT: Sentinel $0.0023 (green, slow counter) |
| RIGHT: GPT-4o $1.4200 (red, fast counter) |
| BOTTOM: "You saved $1.42 (99.8%)" |
| ``` |
|
|
| ### Implementation Plan |
|
|
| **Step 1: Add cost ticker HTML to Monitor tab** |
|
|
| In sensor_bridge.py, add HTML that gets injected: |
| ```html |
| <div id="cost-ticker" style=" |
| display: none; |
| margin-top: 12px; |
| padding: 14px; |
| background: rgba(0,0,0,0.3); |
| border-radius: 12px; |
| border: 1px solid rgba(255,255,255,0.08); |
| font-family: monospace; |
| "> |
| <div style="display: flex; justify-content: space-between; align-items: center;"> |
| <div style="text-align: center;"> |
| <div style="color: #4ade80; font-size: 11px;">SENTINEL</div> |
| <div id="ticker-sentinel" style="color: #4ade80; font-size: 22px; font-weight: bold;">$0.0000</div> |
| </div> |
| <div style="color: #6b7280; font-size: 20px;">vs</div> |
| <div style="text-align: center;"> |
| <div style="color: #ef4444; font-size: 11px;">GPT-4o VISION</div> |
| <div id="ticker-gpt4o" style="color: #ef4444; font-size: 22px; font-weight: bold;">$0.0000</div> |
| </div> |
| </div> |
| <div id="ticker-savings" style=" |
| text-align: center; |
| margin-top: 8px; |
| color: #a5b4fc; |
| font-size: 13px; |
| ">Savings: $0.00 (0%)</div> |
| <div style=" |
| height: 4px; |
| background: rgba(255,255,255,0.1); |
| border-radius: 2px; |
| margin-top: 8px; |
| overflow: hidden; |
| "> |
| <div id="ticker-bar" style=" |
| height: 100%; |
| background: linear-gradient(90deg, #4ade80, #22d3ee); |
| border-radius: 2px; |
| width: 0%; |
| transition: width 0.5s ease-out; |
| "></div> |
| </div> |
| </div> |
| ``` |
| |
| **Step 2: Add animation JS** |
| |
| ```javascript |
| // Inside IIFE: |
| const GPT4O_COST_PER_FRAME = 0.01; // $0.01 per frame at 2 FPS |
| let displayedSentinel = 0; |
| let displayedGPT4o = 0; |
|
|
| function updateCostTicker() { |
| if (!tickerActive) return; |
| |
| // Read actual values from Gradio state |
| const sentinelCost = parseFloat( |
| document.querySelector('#cost-total input')?.value || '0' |
| ); |
| const framesProcessed = parseInt( |
| document.querySelector('#frame-count input')?.value || '0' |
| ); |
| const gpt4oCost = framesProcessed * GPT4O_COST_PER_FRAME; |
| |
| // Animate numbers toward target (easing) |
| displayedSentinel += (sentinelCost - displayedSentinel) * 0.15; |
| displayedGPT4o += (gpt4oCost - displayedGPT4o) * 0.15; |
| |
| document.getElementById("ticker-sentinel").textContent = |
| `$${displayedSentinel.toFixed(4)}`; |
| document.getElementById("ticker-gpt4o").textContent = |
| `$${displayedGPT4o.toFixed(2)}`; |
| |
| const savings = displayedGPT4o - displayedSentinel; |
| const savingsPct = displayedGPT4o > 0 ? (savings / displayedGPT4o * 100) : 0; |
| document.getElementById("ticker-savings").textContent = |
| `You saved $${savings.toFixed(2)} (${savingsPct.toFixed(1)}%)`; |
| |
| // Update progress bar |
| const barWidth = Math.min(100, savingsPct); |
| document.getElementById("ticker-bar").style.width = `${barWidth}%`; |
| } |
| |
| // Run at 30 FPS for smooth animation |
| setInterval(updateCostTicker, 33); |
| ``` |
| |
| **Step 3: Expose frame count to JS** |
| |
| Add hidden component in app.py: |
| ```python |
| frame_count_display = gr.Number(value=0, visible=False, elem_id="frame-count") |
| ``` |
| |
| Update it in process_frame return or via the existing frame_count_state. |
|
|
| **Step 4: Show ticker on activation** |
|
|
| In activation handler: `document.getElementById("cost-ticker").style.display = "block";` |
|
|
| ### What You Need to Approve |
| - GPT-4o cost rate: $0.01/frame is approximate. Use different number? |
| - Number animation: smooth easing (shown) or instant snap? |
| - Should there be confetti when savings hits $1.00? |
| - Placement: inside glass-panel below camera? or separate section? |
|
|
| ### Testing Sequence |
| 1. Activate β ticker shows $0.00 vs $0.00 |
| 2. Run 5 simulation scenarios β Sentinel: ~$0.0015 vs GPT-4o: ~$0.05 |
| 3. Watch numbers animate smoothly |
| 4. Progress bar fills as savings grow |
| 5. After 50 frames: "$0.49 saved (99.8%)" β powerful demo moment |
|
|
| --- |
|
|
| ## UPGRADE 8: Multi-Turn VLM Memory |
|
|
| **Goal:** Nemotron sees last 3 alerts and reasons about trends. "Person was 5m away, now 1m." |
| **Files touched:** `app.py` only (~30 lines). |
| **Dependency:** None. |
|
|
| ### Architecture |
|
|
| ``` |
| NEW gr.State: vlm_memory = gr.State(value=[]) |
| |
| On each VLM call: |
| 1. Append current alert context to vlm_memory (max 3 entries) |
| 2. Format memory into system prompt: |
| "Previous alerts in this session: |
| [10:05:01] WARNING: Person detected 5m away |
| [10:05:11] WARNING: Person now 3m away, approaching |
| [current] Analyze this new frame..." |
| 3. Nemotron receives history + current frame β can reference trends |
| 4. Trim memory to last 3 entries (sliding window) |
| ``` |
|
|
| ### Implementation Plan |
|
|
| **Step 1: Add memory state** |
|
|
| In app.py, with other gr.State declarations: |
| ```python |
| vlm_memory = gr.State(value=[]) # List of dicts: {time, level, text} |
| ``` |
|
|
| **Step 2: Update SYSTEM_PROMPT** |
| |
| Change from static string to a function: |
| ```python |
| def build_system_prompt(memory: list) -> str: |
| history_block = "" |
| if memory: |
| lines = [f" [{m['time']}] {m['level']}: {m['text']}" for m in memory[-3:]] |
| history_block = "\n\nPrevious alerts in this session (use for trend awareness):\n" + "\n".join(lines) |
| |
| return f"""You are Sentinel, an autonomous AI guardian for visually impaired and elderly users. |
| You receive visual descriptions and sensor data. Your job is to: |
| 1. Identify potential dangers (tripping hazards, approaching vehicles, strangers, fire) |
| 2. Provide navigation guidance (door ahead, stairs, obstacles) |
| 3. Alert ONLY when genuinely dangerous β avoid false alarms |
| 4. Respond in 1-2 sentences maximum (user hears this via TTS) |
| 5. Reference previous alerts when relevant (e.g., "Person getting closer") |
| 6. Escalate if a threat is worsening over time |
| |
| Format: [LEVEL] message |
| Where LEVEL is: CRITICAL, WARNING, or OK{history_block}""" |
| ``` |
| |
| **Step 3: Update process_frame** |
| |
| After receiving VLM response and parsing alert: |
| ```python |
| # Append to memory |
| if alert_level in ["critical", "warning"]: |
| memory_entry = { |
| "time": time.strftime("%H:%M:%S"), |
| "level": alert_level.upper(), |
| "text": alert_text[:80] # Truncate to save tokens |
| } |
| vlm_memory.append(memory_entry) |
| vlm_memory = vlm_memory[-3:] # Keep last 3 only |
| |
| # Use dynamic prompt: |
| question = f"Analyze this scene. Context: {sensor_context}" |
| # Pass build_system_prompt(vlm_memory) instead of static SYSTEM_PROMPT |
| ``` |
| |
| **Step 4: Update function signature and event bindings** |
| |
| Add `vlm_memory` to process_frame params (now 21 params, 15 returns, 9 gr.State). |
| Update all return paths and image_data.change bindings accordingly. |
|
|
| ### Token Budget |
| - Each memory entry: ~20 tokens |
| - 3 entries: ~60 extra tokens per VLM call |
| - Negligible cost increase, significant intelligence increase |
|
|
| ### Testing Sequence |
| 1. Trigger "Person Approaching" 3 times in sequence |
| 2. First alert: "Person detected 5m away" |
| 3. Second alert: "Person closer, now 3m" (should reference previous) |
| 4. Third alert: "CRITICAL: Person very close, 1m away β was 5m moments ago" |
| 5. Verify memory shows in alert history with trend references |
|
|
| --- |
|
|
| ## UPGRADE 9: Virtual Walk Mode |
|
|
| **Goal:** Pre-recorded walking video plays. Sentinel analyzes it live. Alerts interrupt the video. |
| **Files touched:** `app.py` (~80 lines new tab), `sensor_bridge.py` (~60 lines JS). |
| **Dependency:** Upgrade 3 (spatial audio) recommended for full experience. |
|
|
| ### Architecture |
|
|
| ``` |
| New Tab: "π¬ Demo" |
| |
| Components: |
| - gr.Video (pre-loaded walking video) |
| - gr.HTML (alert overlay zone, positioned over video) |
| - Play/Pause button |
| - Speed control (0.5x, 1x, 2x) |
| |
| JS Frame Extractor: |
| - requestVideoFrameCallback on video element |
| - Every 1000ms (1 FPS): draw video frame to canvas |
| - Convert to base64 |
| - Inject into image-data textbox β triggers process_frame pipeline |
| |
| Alert Overlay: |
| - When alert generated, HTML overlay appears ON TOP of video |
| - Semi-transparent banner with alert text |
| - Auto-dismisses after 5 seconds |
| - Audio plays via spatial audio (Upgrade 3) or regular playback |
| |
| Data Flow: |
| video.play() β JS extracts frame every 1s β base64 β image-data |
| β process_frame β VLM β alert β overlay on video + TTS |
| β video continues playing underneath |
| ``` |
|
|
| ### Implementation Plan |
|
|
| **Step 1: Create Demo tab in app.py** |
|
|
| ```python |
| with gr.Tab("π¬ Demo"): |
| with gr.Column(elem_classes="glass-panel"): |
| gr.Markdown("### Virtual Walk Mode") |
| gr.Markdown("Watch Sentinel analyze a real walking scenario in real-time.") |
| |
| demo_video = gr.Video( |
| label="Walking Scenario", |
| value=None, # User uploads or pre-load |
| autoplay=False, |
| elem_id="demo-video" |
| ) |
| |
| # Pre-loaded scenario buttons |
| with gr.Row(): |
| gr.Markdown("**Or try a preset scenario:**") |
| with gr.Row(): |
| demo_stairs_btn = gr.Button("Stairs", size="sm") |
| demo_traffic_btn = gr.Button("Traffic", size="sm") |
| demo_park_btn = gr.Button("Park Walk", size="sm") |
| |
| demo_alert_overlay = gr.HTML( |
| "", |
| elem_id="demo-alert-overlay" |
| ) |
| |
| demo_status = gr.HTML( |
| "<div style='text-align:center;color:#9ca3af;'>Press Play to start the virtual walk</div>", |
| elem_id="demo-status" |
| ) |
| ``` |
|
|
| **Step 2: JS frame extractor in sensor_bridge.py** |
| |
| ```javascript |
| function initDemoMode() { |
| const video = document.querySelector('#demo-video video'); |
| if (!video) return; |
| |
| let demoCanvas = document.createElement('canvas'); |
| demoCanvas.width = 640; |
| demoCanvas.height = 480; |
| let demoCtx = demoCanvas.getContext('2d'); |
| let lastDemoFrame = 0; |
| |
| video.addEventListener('play', () => { |
| // Activate monitoring for demo mode |
| setGradioValue("sentinel-active-state", "true"); |
| }); |
| |
| function extractDemoFrame(timestamp) { |
| if (video.paused || video.ended) return; |
| |
| if (timestamp - lastDemoFrame >= 1000) { // 1 FPS |
| lastDemoFrame = timestamp; |
| demoCtx.drawImage(video, 0, 0, 640, 480); |
| const base64 = demoCanvas.toDataURL("image/jpeg", 0.7).split(",")[1]; |
| setGradioValue("image-data", base64); |
| } |
| requestAnimationFrame(extractDemoFrame); |
| } |
| |
| video.addEventListener('play', () => { |
| requestAnimationFrame(extractDemoFrame); |
| }); |
| } |
| ``` |
| |
| **Step 3: Alert overlay CSS** |
| |
| ```css |
| #demo-alert-overlay > div { |
| position: absolute; |
| bottom: 80px; |
| left: 50%; |
| transform: translateX(-50%); |
| z-index: 100; |
| max-width: 90%; |
| animation: slideUp 0.3s ease-out; |
| } |
| @keyframes slideUp { |
| from { opacity: 0; transform: translateX(-50%) translateY(20px); } |
| to { opacity: 1; transform: translateX(-50%) translateY(0); } |
| } |
| ``` |
| |
| ### What You Need to Decide |
| - Video source: Film your own walking video? Use Creative Commons? Use a slideshow of sim images instead? |
| - Frame rate: 1 FPS (1 call/sec, cheap) or 2 FPS? |
| - Should alerts PAUSE the video or overlay while playing? |
| - Preset videos: record 3 short clips (stairs, traffic, park) and embed? |
| |
| ### Testing Sequence |
| 1. Upload walking video to Demo tab |
| 2. Press Play β video starts, frame counter ticks |
| 3. When person crosses path β alert banner slides up over video + TTS plays |
| 4. When stairs appear β CRITICAL alert + loud warning |
| 5. Clear section β "OK: Path clear" in info banner |
| |
| --- |
| |
| ## UPGRADE 10: Web Speech API Panic Override |
| |
| **Goal:** Browser listens for "HELP"/"STOP" keywords. Works offline. Triggers emergency mode. |
| **Files touched:** `sensor_bridge.py` (~50 lines JS). |
| **Dependency:** None. |
|
|
| ### Architecture |
|
|
| ``` |
| webkitSpeechRecognition runs continuously in browser: |
| - Listens for keywords: "help", "stop", "emergency", "cancel" |
| - On match: |
| 1. Set monitoring_active to true (via hidden component) |
| 2. Inject CRITICAL alert into alert banner |
| 3. Play emergency siren sound (Web Audio oscillator) |
| 4. Open SMS app with pre-filled GPS + "EMERGENCY" text |
| - Works even if server is unreachable |
| - iOS requires user gesture to start (use activation button) |
| ``` |
|
|
| ### Implementation Plan |
|
|
| **Step 1: Add speech recognition to sensor_bridge.py IIFE** |
| |
| ```javascript |
| // Inside IIFE: |
| let recognition = null; |
| const PANIC_KEYWORDS = ["help", "stop", "emergency", "cancel", "sos"]; |
| |
| function initSpeechPanic() { |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; |
| if (!SpeechRecognition) { |
| console.log("Web Speech API not available. Panic override disabled."); |
| return; |
| } |
| |
| recognition = new SpeechRecognition(); |
| recognition.continuous = true; |
| recognition.interimResults = true; |
| recognition.lang = 'en-US'; |
| |
| recognition.onresult = (event) => { |
| const last = event.results[event.results.length - 1]; |
| const transcript = last[0].transcript.toLowerCase().trim(); |
| |
| for (const keyword of PANIC_KEYWORDS) { |
| if (transcript.includes(keyword)) { |
| triggerPanicMode(keyword); |
| break; |
| } |
| } |
| }; |
| |
| recognition.onerror = (event) => { |
| // Auto-restart on error (except if user stopped it) |
| if (event.error !== 'aborted' && isActivated) { |
| setTimeout(() => { try { recognition.start(); } catch(e) {} }, 1000); |
| } |
| }; |
| |
| recognition.onend = () => { |
| // Auto-restart if still active |
| if (isActivated) { |
| try { recognition.start(); } catch(e) {} |
| } |
| }; |
| } |
| |
| function triggerPanicMode(keyword) { |
| console.log(`PANIC KEYWORD DETECTED: "${keyword}"`); |
| |
| // 1. Set alert banner to critical |
| const banner = document.getElementById("alert-banner-container"); |
| if (banner) { |
| banner.innerHTML = ` |
| <div class="alert-banner-critical" style="animation: pulse 0.5s infinite;"> |
| π¨ EMERGENCY MODE ACTIVATED<br> |
| Voice command: "${keyword.toUpperCase()}"<br> |
| <small>GPS coordinates being sent to emergency contact</small> |
| </div>`; |
| } |
| |
| // 2. Play emergency siren (Web Audio oscillator) |
| playEmergencySiren(); |
| |
| // 3. Open SMS with pre-filled message |
| const lat = document.querySelector('#gps-lat input')?.value || 'unknown'; |
| const lon = document.querySelector('#gps-lon input')?.value || 'unknown'; |
| const smsBody = encodeURIComponent( |
| `SENTINEL EMERGENCY: User triggered panic mode. Last known location: ${lat}, ${lon}. Please check on them.` |
| ); |
| window.open(`sms:?body=${smsBody}`, '_blank'); |
| } |
| |
| function playEmergencySiren() { |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); |
| const osc = ctx.createOscillator(); |
| const gain = ctx.createGain(); |
| osc.type = 'sawtooth'; |
| osc.frequency.value = 800; |
| gain.gain.value = 0.3; |
| osc.connect(gain); |
| gain.connect(ctx.destination); |
| osc.start(); |
| // Modulate frequency for siren effect |
| osc.frequency.setValueAtTime(800, ctx.currentTime); |
| osc.frequency.linearRampToValueAtTime(1200, ctx.currentTime + 0.5); |
| osc.frequency.linearRampToValueAtTime(800, ctx.currentTime + 1.0); |
| // Auto-stop after 3 seconds |
| gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 3.0); |
| osc.stop(ctx.currentTime + 3.0); |
| } |
| ``` |
| |
| **Step 2: Start recognition on activation** |
|
|
| In `startAllSensors()`, add: `initSpeechPanic(); if (recognition) try { recognition.start(); } catch(e) {}` |
|
|
| In `stopAllSensors()`, add: `if (recognition) try { recognition.stop(); } catch(e) {}` |
|
|
| ### Edge Cases |
| - **iOS Safari:** Requires user gesture. Since activation button is the user gesture, speech recognition starts after clicking ACTIVATE. |
| - **Background noise false triggers:** Use `interimResults: true` but only trigger on high-confidence matches. Could add confidence threshold: `if (last[0].confidence > 0.7)`. |
| - **Browser doesn't support:** Graceful log message, no crash. |
|
|
| ### Testing Sequence |
| 1. Activate Sentinel |
| 2. Say "Help" clearly β banner turns red, siren plays, SMS app opens |
| 3. Say "Stop" β same emergency response |
| 4. Normal conversation β no false triggers |
| 5. Test with server offline β still works (pure browser-side) |
|
|
| --- |
|
|
| ## BUILD ORDER & TIMELINE |
|
|
| ### Day 1 β Deploy + Visual Wins (6 hours) |
|
|
| | Time | Upgrade | Why First | |
| |------|---------|-----------| |
| | 1h | Deploy Modal + HF Space | Everything depends on this | |
| | 1h | Upgrade 6: IMU Glass UI | Simplest visual upgrade, 40 lines | |
| | 1h | Upgrade 7: Cost Race Ticker | Most persuasive demo element | |
| | 1h | Upgrade 4: SVG Bounding Boxes | Camera feed looks like real AI | |
| | 1h | Upgrade 2: Gyroscope HUD | Premium app feel | |
| | 1h | Quick wins (tab badges, keyboard shortcuts) | Polish | |
|
|
| ### Day 2 β Intelligence + Audio (5 hours) |
|
|
| | Time | Upgrade | Why This Order | |
| |------|---------|----------------| |
| | 1h | Upgrade 8: Multi-Turn VLM Memory | Pure app.py change, smart | |
| | 1h | Upgrade 1: OpenCV.js Frame Diff | Architecture proof | |
| | 1.5h | Upgrade 3: Spatial Audio | Demo-defining feature | |
| | 1h | Upgrade 5: TTS Streaming | Pairs with spatial audio | |
| | 0.5h | Upgrade 10: Speech Panic Override | Simple, impressive | |
|
|
| ### Day 3 β Killer Demo + Submission (5 hours) |
|
|
| | Time | Task | Notes | |
| |------|------|-------| |
| | 1h | Upgrade 9: Virtual Walk Mode | Record walking video first | |
| | 1h | Record demo video | Show all upgrades working together | |
| | 1h | HF Dataset (500 rows) | Export from simulation + demo runs | |
| | 1h | Space README + thumbnail | Architecture diagram, screenshots | |
| | 1h | Submit + blog post | Link everything | |
|
|
| --- |
|
|
| ## DEPENDENCY MAP |
|
|
| ``` |
| No dependencies: |
| Upgrade 2 (HUD) βββββββββββββββββββββββ |
| Upgrade 6 (Glass UI) βββββββββββββββββ€ |
| Upgrade 7 (Cost Ticker) ββββββββββββββ€ |
| Upgrade 8 (VLM Memory) ββββββββββββββ€ |
| Upgrade 10 (Speech Panic) ββββββββββββ€ |
| β |
| Optional dependencies: β |
| Upgrade 1 (Frame Diff) β Upgrade 4 (SVG Boxes) |
| Upgrade 3 (Spatial Audio) β Upgrade 9 (Virtual Walk) |
| Upgrade 5 (TTS Stream) β Upgrade 3 (Spatial Audio) |
| β |
| All feed into: ββββββββββββββββββββββββββ |
| Upgrade 9 (Virtual Walk) β uses everything |
| ``` |
|
|
| --- |
|
|
| ## WHAT YOU APPROVE BEFORE AGENT BUILDS |
|
|
| For each upgrade, tell the agent: |
|
|
| 1. **"Build Upgrade X"** β agent starts |
| 2. **"Use approach Y"** β if there are options (like HUD Option A vs B) |
| 3. **"Reference: [screenshot/description]"** β for visual upgrades, describe what you want |
| 4. **"Skip the [thing I don't want]"** β agent trims unnecessary parts |
|
|
| The agent has all the architecture. It just needs your green light on style choices. |
|
|