main.ts (9979B)
1 import "./style.css"; 2 import init, { 3 GeedbackProcessor, 4 Waveform, 5 } from "../geedback-dsp/pkg/geedback_dsp.js"; 6 import { renderAbout } from "./about"; 7 8 const app = document.querySelector<HTMLDivElement>("#app")!; 9 10 let processor: GeedbackProcessor | null = null; 11 let audioCtx: AudioContext | null = null; 12 let isActive = false; 13 let wasmInitialized = false; 14 15 // UI Elements (updated in renderHome) 16 let startBtn: HTMLButtonElement | null = null; 17 let wasmOutput: HTMLSpanElement | null = null; 18 let canvas: HTMLCanvasElement | null = null; 19 let touchXEl: HTMLSpanElement | null = null; 20 let touchYEl: HTMLSpanElement | null = null; 21 22 const updateRow = ( 23 id: string, 24 x: number | null, 25 y?: number | null, 26 z?: number | null, 27 ) => { 28 const row = document.getElementById(id); 29 if (!row) return; 30 if (x !== null) { 31 const xEl = row.querySelector(".x"); 32 if (xEl) xEl.textContent = x.toFixed(2); 33 } 34 if (y !== undefined && y !== null) { 35 const yEl = row.querySelector(".y"); 36 if (yEl) yEl.textContent = y.toFixed(2); 37 } 38 if (z !== undefined && z !== null) { 39 const zEl = row.querySelector(".z"); 40 if (zEl) zEl.textContent = z.toFixed(2); 41 } 42 }; 43 44 const setupWaveformControls = () => { 45 [0, 1, 2].forEach((i) => { 46 const select = document.getElementById( 47 `osc-${i}-wave`, 48 ) as HTMLSelectElement; 49 if (!select) return; 50 select.addEventListener("change", (e) => { 51 if (processor) { 52 const val = (e.target as HTMLSelectElement).value; 53 processor.set_waveform(i, (Waveform as any)[val]); 54 } 55 }); 56 // Restore state if processor already exists 57 // (Simplified: just default to Sine or use processor state if we had getters) 58 }); 59 }; 60 61 const drawPad = (x: number, y: number) => { 62 if (!canvas) return; 63 const ctx = canvas.getContext("2d")!; 64 ctx.clearRect(0, 0, canvas.width, canvas.height); 65 66 // Grid 67 ctx.strokeStyle = "#222"; 68 ctx.lineWidth = 1; 69 for (let i = 1; i < 4; i++) { 70 ctx.beginPath(); 71 ctx.moveTo((i * canvas.width) / 4, 0); 72 ctx.lineTo((i * canvas.width) / 4, canvas.height); 73 ctx.stroke(); 74 ctx.beginPath(); 75 ctx.moveTo(0, (i * canvas.height) / 4); 76 ctx.lineTo(canvas.width, (i * canvas.height) / 4); 77 ctx.stroke(); 78 } 79 80 // Touch point - RED 81 ctx.fillStyle = "#ff4444"; 82 ctx.beginPath(); 83 ctx.arc(x * canvas.width, y * canvas.height, 10, 0, Math.PI * 2); 84 ctx.fill(); 85 86 // Crosshair - RED 87 ctx.strokeStyle = "#ff4444"; 88 ctx.lineWidth = 2; 89 ctx.beginPath(); 90 ctx.moveTo(x * canvas.width, 0); 91 ctx.lineTo(x * canvas.width, canvas.height); 92 ctx.stroke(); 93 ctx.beginPath(); 94 ctx.moveTo(0, y * canvas.height); 95 ctx.lineTo(canvas.width, y * canvas.height); 96 ctx.stroke(); 97 }; 98 99 const setupKaossPad = () => { 100 if (!canvas) return; 101 let isTouching = false; 102 103 const updateTouch = (e: MouseEvent | TouchEvent) => { 104 if (!isActive || !canvas) return; 105 const rect = canvas.getBoundingClientRect(); 106 let clientX, clientY; 107 108 if ("touches" in e) { 109 clientX = e.touches[0].clientX; 110 clientY = e.touches[0].clientY; 111 } else { 112 clientX = (e as MouseEvent).clientX; 113 clientY = (e as MouseEvent).clientY; 114 } 115 116 const x = (clientX - rect.left) / rect.width; 117 const y = (clientY - rect.top) / rect.height; 118 const clampedX = Math.max(0, Math.min(1, x)); 119 const clampedY = Math.max(0, Math.min(1, y)); 120 121 if (processor) { 122 processor.set_touch(clampedX, clampedY); 123 } 124 125 if (touchXEl) touchXEl.textContent = clampedX.toFixed(2); 126 if (touchYEl) touchYEl.textContent = clampedY.toFixed(2); 127 128 drawPad(clampedX, clampedY); 129 }; 130 131 canvas.width = canvas.offsetWidth; 132 canvas.height = canvas.offsetHeight; 133 drawPad(0.5, 0.5); 134 135 canvas.addEventListener("mousedown", (e) => { 136 isTouching = true; 137 updateTouch(e); 138 }); 139 window.addEventListener("mousemove", (e) => { 140 if (isTouching) updateTouch(e); 141 }); 142 window.addEventListener("mouseup", () => { 143 isTouching = false; 144 }); 145 146 canvas.addEventListener( 147 "touchstart", 148 (e) => { 149 if (e.target === canvas) { 150 isTouching = true; 151 updateTouch(e); 152 e.preventDefault(); 153 } 154 }, 155 { passive: false }, 156 ); 157 158 window.addEventListener( 159 "touchmove", 160 (e) => { 161 if (isTouching) { 162 updateTouch(e); 163 e.preventDefault(); 164 } 165 }, 166 { passive: false }, 167 ); 168 window.addEventListener("touchend", () => { 169 isTouching = false; 170 }); 171 }; 172 173 const startSynth = async () => { 174 // 1. Request Sensor Permissions (iOS 13+) - MUST be first to keep user gesture context 175 try { 176 if (typeof (DeviceMotionEvent as any).requestPermission === "function") { 177 await (DeviceMotionEvent as any).requestPermission(); 178 } 179 if ( 180 typeof (DeviceOrientationEvent as any).requestPermission === "function" 181 ) { 182 await (DeviceOrientationEvent as any).requestPermission(); 183 } 184 } catch (e) { 185 console.error("Sensor Permission Error:", e); 186 } 187 188 if (!wasmInitialized) { 189 try { 190 await init(); 191 wasmInitialized = true; 192 processor = new GeedbackProcessor(); 193 } catch (e) { 194 alert("WASM failed"); 195 return; 196 } 197 } 198 199 audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); 200 if (processor) processor.set_sample_rate(audioCtx.sampleRate); 201 202 const scriptNode = audioCtx.createScriptProcessor(4096, 0, 1); 203 scriptNode.onaudioprocess = (e) => { 204 const out = e.outputBuffer.getChannelData(0); 205 if (processor && isActive) { 206 for (let i = 0; i < out.length; i++) out[i] = processor.process(); 207 } else { 208 for (let i = 0; i < out.length; i++) out[i] = 0; 209 } 210 }; 211 scriptNode.connect(audioCtx.destination); 212 if (audioCtx.state === "suspended") await audioCtx.resume(); 213 214 isActive = true; 215 if (startBtn) { 216 startBtn.textContent = "Stop Audio & Sensors"; 217 startBtn.classList.add("active"); 218 } 219 }; 220 221 const stopSynth = async () => { 222 isActive = false; 223 if (audioCtx) { 224 await audioCtx.close(); 225 audioCtx = null; 226 } 227 if (startBtn) { 228 startBtn.textContent = "Start Audio & Sensors"; 229 startBtn.classList.remove("active"); 230 } 231 }; 232 233 const renderHome = () => { 234 app.innerHTML = ` 235 <div> 236 <nav style="text-align: right;"> 237 <a href="#about">About</a> 238 </nav> 239 <h1>Geedback</h1> 240 <p>Multi-oscillator synthesizer feedback of gravity for Smartphones</p> 241 <button id="start-btn">${isActive ? "Stop Audio & Sensors" : "Start Audio & Sensors"}</button> 242 243 <canvas id="kaoss-pad"></canvas> 244 245 <div style="margin-top: 20px; padding: 15px; border: 2px solid #ff4444; border-radius: 8px; background: #1a1a1a;"> 246 <div style="display: flex; justify-content: space-between; margin-bottom: 10px;"> 247 <strong>DSP Output:</strong> <span id="wasm-output" style="font-family: monospace;">-</span> 248 </div> 249 <div style="font-size: 0.8em; color: #888; margin-bottom: 10px;"> 250 Cutoff (X): <span id="touch-x">-</span> | Resonance (Y): <span id="touch-y">-</span> 251 </div> 252 253 <div class="osc-controls"> 254 ${[0, 1, 2] 255 .map( 256 (i) => ` 257 <div style="margin-bottom: 8px;"> 258 <label>Osc ${i + 1} Wave:</label> 259 <select id="osc-${i}-wave" class="wave-select"> 260 <option value="Sine">Sine</option> 261 <option value="Triangle">Triangle</option> 262 <option value="TriangleSawtooth">Tri-Saw</option> 263 <option value="Sawtooth">Sawtooth</option> 264 <option value="ReverseSawtooth">Rev-Saw</option> 265 <option value="Square">Square</option> 266 <option value="WidePulse">Wide Pulse</option> 267 <option value="NarrowPulse">Narrow Pulse</option> 268 </select> 269 </div> 270 `, 271 ) 272 .join("")} 273 </div> 274 </div> 275 276 <table border="1" style="margin-top: 20px; width: 100%; border-collapse: collapse; font-size: 0.8em; opacity: 0.7;"> 277 <tbody id="sensor-table"> 278 <tr id="row-accel"><td>Accel</td><td class="x">-</td><td class="y">-</td><td class="z">-</td></tr> 279 <tr id="row-linear"><td>Linear</td><td class="x">-</td><td class="y">-</td><td class="z">-</td></tr> 280 <tr id="row-orient"><td>Orient</td><td class="x">-</td><td class="y">-</td><td class="z">-</td></tr> 281 </tbody> 282 </table> 283 </div> 284 `; 285 286 startBtn = document.querySelector<HTMLButtonElement>("#start-btn")!; 287 wasmOutput = document.querySelector<HTMLSpanElement>("#wasm-output")!; 288 canvas = document.querySelector<HTMLCanvasElement>("#kaoss-pad")!; 289 touchXEl = document.querySelector<HTMLSpanElement>("#touch-x")!; 290 touchYEl = document.querySelector<HTMLSpanElement>("#touch-y")!; 291 292 if (isActive) startBtn.classList.add("active"); 293 294 startBtn.addEventListener("click", () => { 295 if (!isActive) { 296 startSynth(); 297 } else { 298 stopSynth(); 299 } 300 }); 301 302 setupWaveformControls(); 303 setupKaossPad(); 304 }; 305 306 const router = () => { 307 const hash = window.location.hash; 308 if (hash === "#about") { 309 renderAbout(app); 310 } else { 311 renderHome(); 312 } 313 }; 314 315 window.addEventListener("hashchange", router); 316 317 // Setup global event listeners once 318 window.addEventListener("devicemotion", (e) => { 319 if (!isActive || !processor) return; 320 const la = e.acceleration; 321 if (la) { 322 processor.set_linear_accel(la.x || 0, la.y || 0, la.z || 0); 323 updateRow("row-linear", la.x, la.y, la.z); 324 } 325 const a = e.accelerationIncludingGravity; 326 if (a) updateRow("row-accel", a.x, a.y, a.z); 327 }); 328 329 window.addEventListener("deviceorientation", (e) => { 330 if (!isActive || !processor) return; 331 processor.set_orient(e.alpha || 0, e.beta || 0, e.gamma || 0); 332 updateRow("row-orient", e.alpha, e.beta, e.gamma); 333 }); 334 335 const uiLoop = () => { 336 if (processor && isActive && wasmOutput) 337 wasmOutput.textContent = processor.get_latest_output().toFixed(6); 338 requestAnimationFrame(uiLoop); 339 }; 340 341 // Start 342 router(); 343 uiLoop();