added websocket for separate computers on same wifi/lan

This commit is contained in:
Jing Kohn 2025-11-24 20:06:16 +10:00
parent 3c9646cf0b
commit d3f7f374ab
5 changed files with 147 additions and 17 deletions

View File

@ -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 WiFi/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.
### OneTime 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 Computers IP Address
- Windows: run `ipconfig` → use the `IPv4 Address` on your active adapter (e.g., `192.168.1.5`).
- macOS: run `ipconfig getifaddr en0` (WiFi) 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://<THERAPIST_IP>: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://<THERAPIST_IP>: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`

View File

@ -224,6 +224,23 @@
<button data-mult="3">3×</button>
<button data-mult="4">4×</button>
</div>
<div class="panel" style="margin-top:8px">
<h2>Network Mode</h2>
<div class="row">
<label>Server URL:</label>
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" style="width:220px">
<label>Session ID:</label>
<input type="text" id="wsSession" placeholder="room-1" style="width:120px">
<button id="wsConnect">Connect</button>
<button id="wsDisconnect">Disconnect</button>
<span class="small">Status: <span id="wsStatus">Disconnected</span></span>
</div>
<div class="row">
<label>Patient Join Link:</label>
<input type="text" id="joinLink" readonly style="flex:1; min-width:260px">
<button id="copyJoinLink">Copy</button>
</div>
</div>
<div class="row">

View File

@ -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<names.length;i++){ var n=names[i]; html += '<option value="'+n+'">'+n+'</option>' } 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' }

View File

@ -24,7 +24,14 @@ BLS<!doctype html>
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

41
server.py Normal file
View File

@ -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())