From d3f7f374ab74920e67274db91bd101ade581965f Mon Sep 17 00:00:00 2001 From: Jing Kohn Date: Mon, 24 Nov 2025 20:06:16 +1000 Subject: [PATCH] added websocket for separate computers on same wifi/lan --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 17 +++++++++++++++++ js/admin.js | 51 ++++++++++++++++++++++++++++++++++----------------- patient.html | 7 +++++++ server.py | 41 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 server.py diff --git a/README.md b/README.md index 5e33dec..6d0967d 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,54 @@ Simple browser-based tool with an Admin panel and a full-screen Patient view. Th - Open `index.html` and use the Admin panel - Click “Open Patient View” to launch `patient.html` in a separate tab/window +## Network Mode Setup (Two Computers on the Same Wi‑Fi/LAN) + +### Overview +- Therapist runs a lightweight relay (`server.py`) once on their computer. +- Admin connects to the relay with a Session ID and copies the Patient Join Link. +- Patient opens the link on their computer and performs a quick gesture to unlock audio. + +### One‑Time Install (WebSocket Relay) +- Windows: + - Press `Windows` key → type `PowerShell` → `Enter` + - Check Python: `python --version` (or `py --version`) + - Install package: `python -m pip install websockets` (or `py -m pip install websockets`) + - Start relay: `python server.py` (or `py server.py`) + - If prompted by Windows Defender Firewall, click “Allow access”. +- macOS: + - Press `Cmd+Space` → type `Terminal` → `Enter` + - Check Python: `python3 --version` + - Install package: `python3 -m pip install websockets` + - Start relay: `python3 server.py` +- Linux (Ubuntu/Debian/Fedora etc.): + - Press `Ctrl+Alt+T` to open Terminal + - Check Python: `python3 --version` + - Install package: `python3 -m pip install websockets` + - Start relay: `python3 server.py` + +### Find Your Therapist Computer’s IP Address +- Windows: run `ipconfig` → use the `IPv4 Address` on your active adapter (e.g., `192.168.1.5`). +- macOS: run `ipconfig getifaddr en0` (Wi‑Fi) or `ipconfig getifaddr en1` (alternate); or `ifconfig | grep "inet "`. +- Linux: run `hostname -I` and pick the local `192.168.x.x` address; or `ip addr show`. + +### Connect Admin (Therapist Computer) +1) Open Admin in a browser: `http://localhost:8000/` +2) In “Network Mode”: + - Server URL: `ws://:8787` (e.g., `ws://192.168.1.5:8787`) + - Session ID: any label you like (e.g., `room-1`) + - Click `Connect` +3) Copy the Patient Join Link shown below the connection fields. + +### Connect Patient (Second Computer) +1) Open the copied Patient Join Link in a browser +2) Perform a quick gesture (mouse move or key press) to unlock audio (browser policy) +3) The Patient view will mirror Admin changes in real time + +### Tips +- If the Patient does not connect, verify both computers are on the same network and can reach `ws://:8787`. +- On Windows, allow the relay through the firewall when prompted. +- You can disconnect Network Mode and continue Local Mode at any time. + ## Data Storage - Presets: `localStorage` key `bls_presets` diff --git a/index.html b/index.html index 0a489b5..e069533 100644 --- a/index.html +++ b/index.html @@ -224,6 +224,23 @@ +
+

Network Mode

+
+ + + + + + + Status: Disconnected +
+
+ + + +
+
diff --git a/js/admin.js b/js/admin.js index 41deb2f..001ec4b 100644 --- a/js/admin.js +++ b/js/admin.js @@ -105,6 +105,7 @@ function updateTimerLabel(){ var m = Math.max(0, parseInt(timerMinutes.value||'0 function recordSession(){ var n = (patientName.value||'').trim()||'Unnamed'; var st = BLS.loadState(); if(!st.startedAt) return; var end = Date.now(); var durationSec = Math.floor((end - st.startedAt)/1000); var p = getPatient(n); p.sessions.push({ start: st.startedAt, end: end, durationSec: durationSec }); p.count = (p.count||0)+1; p.last = { start: st.startedAt, end: end, durationSec: durationSec }; setPatient(p); refreshPatientUI() } function refreshPresetList(){ var names = BLS.listPresets(); var html=''; for(var i=0;i'+n+'' } presetList.innerHTML = html } function applyState(next, broadcast){ state = BLS.mergeState(state, next||{}); renderer.setState(state); if(state.running){ renderer.start() } else { renderer.stop() } if(broadcast){ chan.send(state) } } + function publishState(){ try{ if(wsConnected && ws){ ws.send(JSON.stringify({type:'state', sessionId: wsSid||'default', payload: BLS.loadState()})) } }catch(e){} } shapeControls.addEventListener('click', function(e){ var d = e.target.dataset.shape; if(!d) return; applyState({shape:d}, true); setActive(shapeControls,'shape','shape'); }); directionControls.addEventListener('click', function(e){ var d = e.target.dataset.direction; if(!d) return; applyState({direction:d}, true); setActive(directionControls,'direction','direction'); }); easingControls.addEventListener('click', function(e){ var d = e.target.dataset.easing; if(!d) return; applyState({easingMode:d}, true); setActive(easingControls,'easing','easingMode'); }); @@ -112,18 +113,18 @@ shapeControls.addEventListener('click', function(e){ var d = e.target.dataset.sh bgColor.addEventListener('input', function(){ applyState({bgColor:bgColor.value}, true); }); shapePreset.addEventListener('click', function(e){ var c = e.target.dataset.color; if(!c) return; shapeColor.value = c; applyState({shapeColor:c}, true); }); bgPreset.addEventListener('click', function(e){ var c = e.target.dataset.color; if(!c) return; bgColor.value = c; applyState({bgColor:c}, true); }); - speed.addEventListener('input', function(){ updateSpeedLabel(); var m = (BLS.loadState().speedMultiplier||1); applyState({speed:parseInt(speed.value,10)*m}, true); }); + speed.addEventListener('input', function(){ updateSpeedLabel(); var m = (BLS.loadState().speedMultiplier||1); applyState({speed:parseInt(speed.value,10)*m}, true); publishState() }); size.addEventListener('input', function(){ updateSizeLabel(); applyState({size:parseInt(size.value,10)}, true); }); yPercent.addEventListener('input', function(){ updateYPercentLabel(); applyState({yPercent:parseInt(yPercent.value,10)}, true); }); yReset.addEventListener('click', function(){ yPercent.value=50; updateYPercentLabel(); applyState({yPercent:50}, true); }); - glowEnable.addEventListener('change', function(){ applyState({glowEnabled: glowEnable.checked}, true) }); - glowIntensity.addEventListener('input', function(){ updateGlowIntensityLabel(); var f = parseInt(glowIntensity.value,10)/100; applyState({glowIntensity:f}, true) }); - glowRate.addEventListener('input', function(){ updateGlowRateLabel(); applyState({glowRate: parseFloat(glowRate.value)}, true) }); - rampEnable.addEventListener('change', function(){ var st = BLS.loadState(); var next = {rampEnabled: rampEnable.checked}; if(!rampEnable.checked){ next.easingMode = 'linear' } if(st.running && rampEnable.checked){ next.startedAt = Date.now() } applyState(next, true) }); - rampSeconds.addEventListener('input', function(){ updateRampLabel(); var st = BLS.loadState(); var next = {rampSeconds: parseInt(rampSeconds.value,10)}; if(st.running && st.rampEnabled){ next.startedAt = Date.now() } applyState(next, true) }); - edgePause.addEventListener('input', function(){ updateEdgePauseLabel(); applyState({edgePauseMs: parseInt(edgePause.value,10)}, true) }); - wiggleEnable.addEventListener('change', function(){ applyState({wiggleEnabled: wiggleEnable.checked}, true) }); - wiggleAmplitude.addEventListener('input', function(){ updateWiggleLabel(); applyState({wiggleAmplitude: parseInt(wiggleAmplitude.value,10)}, true) }); + glowEnable.addEventListener('change', function(){ applyState({glowEnabled: glowEnable.checked}, true); publishState() }); + glowIntensity.addEventListener('input', function(){ updateGlowIntensityLabel(); var f = parseInt(glowIntensity.value,10)/100; applyState({glowIntensity:f}, true); publishState() }); + glowRate.addEventListener('input', function(){ updateGlowRateLabel(); applyState({glowRate: parseFloat(glowRate.value)}, true); publishState() }); + rampEnable.addEventListener('change', function(){ var st = BLS.loadState(); var next = {rampEnabled: rampEnable.checked}; if(!rampEnable.checked){ next.easingMode = 'linear' } if(st.running && rampEnable.checked){ next.startedAt = Date.now() } applyState(next, true); publishState() }); + rampSeconds.addEventListener('input', function(){ updateRampLabel(); var st = BLS.loadState(); var next = {rampSeconds: parseInt(rampSeconds.value,10)}; if(st.running && st.rampEnabled){ next.startedAt = Date.now() } applyState(next, true); publishState() }); + edgePause.addEventListener('input', function(){ updateEdgePauseLabel(); applyState({edgePauseMs: parseInt(edgePause.value,10)}, true); publishState() }); + wiggleEnable.addEventListener('change', function(){ applyState({wiggleEnabled: wiggleEnable.checked}, true); publishState() }); + wiggleAmplitude.addEventListener('input', function(){ updateWiggleLabel(); applyState({wiggleAmplitude: parseInt(wiggleAmplitude.value,10)}, true); publishState() }); timerMinutes.addEventListener('input', function(){ updateTimerLabel(); }); savePresetBtn.addEventListener('click', function(){ var n = (presetName.value||'').trim(); if(!n) return; BLS.savePreset(n, BLS.loadState()); refreshPresetList() }) applyPresetBtn.addEventListener('click', function(){ var n = presetList.value; if(!n) return; var preset = BLS.loadPreset(n); if(!preset) return; applyState(preset, true); setActive(shapeControls,'shape','shape'); setActive(directionControls,'direction','direction'); setActive(easingControls,'easing','easingMode') }) @@ -136,11 +137,11 @@ shapeControls.addEventListener('click', function(e){ var d = e.target.dataset.sh audioSelect.addEventListener('change', function(){ var name = audioSelect.value || null; applyState({audioName:name}, true) }); audioEnable.addEventListener('change', function(){ applyState({audioEnabled: audioEnable.checked}, true) }); audioMode.addEventListener('change', function(){ refreshAudioMode(); applyState({audioMode: audioMode.value}, true) }); - programPreset.addEventListener('change', function(){ applyState({programPreset: programPreset.value}, true) }); - syncPan.addEventListener('change', function(){ applyState({syncPan: syncPan.checked}, true) }); - panRate.addEventListener('input', function(){ updatePanRateLabel(); applyState({panRate: parseFloat(panRate.value)}, true) }); - cueRate.addEventListener('input', function(){ updateCueRateLabel(); applyState({cueRate: parseFloat(cueRate.value)}, true) }); - volume.addEventListener('input', function(){ updateVolumeLabel(); applyState({volume: parseInt(volume.value,10)/100}, true) }); + programPreset.addEventListener('change', function(){ applyState({programPreset: programPreset.value}, true); publishState() }); + syncPan.addEventListener('change', function(){ applyState({syncPan: syncPan.checked}, true); publishState() }); + panRate.addEventListener('input', function(){ updatePanRateLabel(); applyState({panRate: parseFloat(panRate.value)}, true); publishState() }); + cueRate.addEventListener('input', function(){ updateCueRateLabel(); applyState({cueRate: parseFloat(cueRate.value)}, true); publishState() }); + volume.addEventListener('input', function(){ updateVolumeLabel(); applyState({volume: parseInt(volume.value,10)/100}, true); publishState() }); testSound.addEventListener('click', function(){ try{ var st = BLS.loadState(); if(st.audioMode==='programmatic'){ AudioEngine.setVolume(st.volume||0.6); var pan = 0; if(st.programPreset==='click'){ AudioEngine.playClick(pan) } else if(st.programPreset==='ping'){ AudioEngine.playPing(pan) } else if(st.programPreset==='woodblock'){ AudioEngine.playWoodblock(pan) } else if(st.programPreset==='bell'){ AudioEngine.playBell(pan) } else if(st.programPreset==='bass'){ AudioEngine.playBass(pan) } else if(st.programPreset==='kick'){ AudioEngine.playKick(pan) } else if(st.programPreset==='snare'){ AudioEngine.playSnare(pan) } else if(st.programPreset==='hihat'){ AudioEngine.playHiHat(pan) } else if(st.programPreset==='sweep'){ AudioEngine.playSweep(pan) } else if(st.programPreset==='zap'){ AudioEngine.playZap(pan) } else if(st.programPreset==='bubble'){ AudioEngine.playBubble(pan) } else if(st.programPreset==='pink'){ AudioEngine.startPinkNoisePan(st.panRate||0.5); setTimeout(function(){ AudioEngine.stopNoise() }, 800) } else if(st.programPreset==='hybrid'){ AudioEngine.startHybrid(st.cueRate||1.2); setTimeout(function(){ AudioEngine.stopNoise() }, 1000) } } }catch(e){} }); knobSens.addEventListener('input', function(){ updateKnobSensLabel() }); if(clearSessionsBtn){ clearSessionsBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); p.sessions = []; p.count = 0; p.last = null; setPatient(p); refreshPatientUI() }) } @@ -155,8 +156,13 @@ shapeControls.addEventListener('click', function(e){ var d = e.target.dataset.sh var tickId = null; function startTimer(){ if(tickId) clearInterval(tickId); var durationSec = Math.max(0, (parseInt(timerMinutes.value||'0',10)||0)*60); var startedAt = Date.now(); applyState({running:true, durationSec:durationSec, startedAt:startedAt}, true); statusText.textContent = 'Running'; tickId = setInterval(function(){ var st = BLS.loadState(); if(!st.running){ clearInterval(tickId); tickId=null; return } var elapsed = Math.floor((Date.now()-st.startedAt)/1000); var rem = Math.max(0, st.durationSec - elapsed); var em = Math.floor(elapsed/60), es = elapsed%60; elapsedText.textContent = String(em).padStart(2,'0')+':'+String(es).padStart(2,'0'); var rm = Math.floor(rem/60); timerDisplay.textContent = String(rm).padStart(2,'0')+':'+String(rem%60).padStart(2,'0'); if(rem<=0){ stopTimer() } }, 250); } function stopTimer(){ if(tickId){ clearInterval(tickId); tickId=null } recordSession(); applyState({running:false}, true); statusText.textContent = 'Idle'; refreshPatientUI() } -startBtn.addEventListener('click', startTimer); -endBtn.addEventListener('click', stopTimer); + startBtn.addEventListener('click', function(){ startTimer(); publishState() }); + endBtn.addEventListener('click', function(){ stopTimer(); publishState() }); + wsConnectBtn.addEventListener('click', function(){ wsConnect(); updateJoinLink() }); + wsDisconnectBtn.addEventListener('click', wsDisconnect); + wsUrl.addEventListener('input', updateJoinLink); + wsSession.addEventListener('input', updateJoinLink); + copyJoinLink.addEventListener('click', function(){ if(joinLink.value){ navigator.clipboard && navigator.clipboard.writeText(joinLink.value) } }); openPatient.addEventListener('click', function(){ window.open('patient.html','bls-patient'); }); renderer.resize(360,220); updateSpeedLabel(); updateSizeLabel(); updateYPercentLabel(); updateTimerLabel(); updateGlowIntensityLabel(); updateGlowRateLabel(); updateRampLabel(); updateEdgePauseLabel(); updateWiggleLabel(); updatePanRateLabel(); updateCueRateLabel(); updateVolumeLabel(); statusText.textContent = state.running? 'Running':'Idle'; setActive(shapeControls,'shape','shape'); setActive(directionControls,'direction','direction'); setActive(easingControls,'easing','easingMode'); @@ -179,8 +185,19 @@ endBtn.addEventListener('click', stopTimer); updateKnobSensLabel(); yPercent.value = parseInt(state.yPercent||50,10); updateYPercentLabel(); - refreshPresetList(); refreshPatientList(); + refreshPresetList(); refreshPatientList(); updateJoinLink(); refreshPatientUI(); } function waitForBLS(){ if(window.BLS){ initAdmin() } else { setTimeout(waitForBLS, 50) } } if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', waitForBLS) } else { waitForBLS() } + var wsUrl = document.getElementById('wsUrl'); + var wsSession = document.getElementById('wsSession'); + var wsConnectBtn = document.getElementById('wsConnect'); + var wsDisconnectBtn = document.getElementById('wsDisconnect'); + var wsStatus = document.getElementById('wsStatus'); + var joinLink = document.getElementById('joinLink'); + var copyJoinLink = document.getElementById('copyJoinLink'); + var ws = null; var wsConnected = false; var wsSid = null; + function updateJoinLink(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim(); var base = location.origin + '/patient.html'; if(url && sid){ joinLink.value = base + '?url=' + encodeURIComponent(url) + '&sid=' + encodeURIComponent(sid) } else { joinLink.value = '' } } + function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim()||'default'; if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } ws = new WebSocket(url); wsSid = sid; wsStatus.textContent = 'Connecting...'; ws.onopen = function(){ wsConnected = true; wsStatus.textContent = 'Connected'; try{ ws.send(JSON.stringify({type:'join', sessionId:sid, role:'admin'})) }catch(e){} }; ws.onclose = function(){ wsConnected = false; wsStatus.textContent = 'Disconnected' }; ws.onerror = function(){ wsStatus.textContent = 'Error' }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='joined'){ wsStatus.textContent = 'Connected' } }catch(e){} } } + function wsDisconnect(){ if(ws){ try{ ws.close() }catch(e){} } ws=null; wsConnected=false; wsStatus.textContent = 'Disconnected' } diff --git a/patient.html b/patient.html index bd8de36..a31af96 100644 --- a/patient.html +++ b/patient.html @@ -24,7 +24,14 @@ BLS size(); window.addEventListener('resize', size) var renderer = BLS.Renderer(canvas) var chan = BLS.createChannel('bls-session') + var qs = new URLSearchParams(location.search) + var wsUrl = qs.get('url') + var wsSid = qs.get('sid') || 'default' + var ws = null chan.on(function(msg){ if(!msg || typeof msg !== 'object') return; var apply = Object.assign({}, msg); if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' } if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 } renderer.setState(apply); var st = renderer.getState(); if(st.running){ renderer.start() } else { renderer.stop() } }) + function wsConnect(){ if(!wsUrl) return; try{ ws = new WebSocket(wsUrl) }catch(e){ return } ws.onopen = function(){ try{ ws.send(JSON.stringify({type:'join', sessionId: wsSid, role:'patient'})) }catch(e){} }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='state'){ var apply = Object.assign({}, msg.payload||{}); if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' } if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 } renderer.setState(apply); var st = renderer.getState(); if(st.running){ renderer.start() } else { renderer.stop() } } }catch(e){} }; ws.onclose = function(){ ws = null } + } + if(wsUrl){ wsConnect() } renderer.setState(BLS.loadState()) var st = renderer.getState(); if(st.running){ renderer.start() } var audioCtx = null, panner = null, gain = null, source = null, audioFolder = null, audioBuffer = null diff --git a/server.py b/server.py new file mode 100644 index 0000000..ac07d88 --- /dev/null +++ b/server.py @@ -0,0 +1,41 @@ +import asyncio +import json +import websockets + +SESSIONS = {} + +async def handler(ws): + session_id = None + try: + async for msg in ws: + try: + data = json.loads(msg) + except Exception: + continue + t = data.get('type') + if t == 'join': + session_id = data.get('sessionId') or 'default' + SESSIONS.setdefault(session_id, set()).add(ws) + await ws.send(json.dumps({'type':'joined','sessionId':session_id})) + elif t == 'state': + sid = data.get('sessionId') or session_id or 'default' + payload = data.get('payload') + for peer in list(SESSIONS.get(sid, set())): + if peer is not ws: + try: + await peer.send(json.dumps({'type':'state','payload':payload})) + except Exception: + pass + elif t == 'ping': + await ws.send(json.dumps({'type':'pong'})) + finally: + if session_id and ws in SESSIONS.get(session_id, set()): + SESSIONS[session_id].discard(ws) + +async def main(): + print('Starting BLS relay on ws://0.0.0.0:8787') + async with websockets.serve(handler, '0.0.0.0', 8787, ping_interval=20, ping_timeout=20): + await asyncio.Future() + +if __name__ == '__main__': + asyncio.run(main())