easier instructions and connection for multi-computer over lan

This commit is contained in:
Jing Kohn 2025-11-24 22:45:29 +10:00
parent d3f7f374ab
commit cbd4b25bc6
10 changed files with 399 additions and 39 deletions

112
README.md
View File

@ -1,6 +1,63 @@
# Bilateral Stimulation Simulator
Simple browser-based tool with an Admin panel and a full-screen Patient view. The Admin controls everything; the Patient view mirrors motion and audio.
Simple browser-based tool with an therapist panel and a full-screen Patient view. The therapist controls everything; the Patient view mirrors motion and audio.
## How It Works
- Two pages:
- Therapist (Admin) page: where you set motion, colors, and sounds
- Patient page: fullscreen view that mirrors the Admin
- Two ways to show the Patient page:
- Same computer (extended monitor): plug a second display facing the patient, open the Patient page on that display, go fullscreen — no network setup needed
- Two computers on the same WiFi/LAN: start a tiny local server on the therapists computer so both can communicate, then the patient joins from their computer
- Audio note: most browsers need you to click on the page once to activate sound
- Go gently — adjust speed, colors, and sounds to what feels comfortable; pause whenever needed
## Super Simple Guide
### Windows
- FirstTime Setup
- Doubleclick `setup.bat`
- Allow the Windows Defender Firewall prompt (Private networks)
- Your browser opens at `http://<yourIP>:8000/`; “Relay” turns green
- Every Session
- Doubleclick `start-relay.bat`
- Click “Detect Local IP” in the app; the Patient link autocopies
- Stop Server
- Use “Stop Server” in the app, or press `Ctrl + C` in the relay window
### macOS
- FirstTime Setup
- Press `Cmd + Space` → type `Terminal` → press `Enter`
- `cd` into the folder where you unzipped the app
- Copy and paste: `chmod +x ./setup.sh && ./setup.sh`
- Allow the macOS firewall prompt (Private networks)
- Your browser opens at `http://<yourIP>:8000/`; “Relay” turns green
- Every Session
- Press `Cmd + Space` → type `Terminal``Enter`
- `cd` into the app folder
- Copy and paste: `chmod +x ./start-relay.sh && ./start-relay.sh`
- Click “Detect Local IP”; the Patient link autocopies
- Stop Server
- Use “Stop Server” in the app, or press `Ctrl + C` in the relay Terminal
### Linux
- FirstTime Setup
- Press `Ctrl + Alt + T` to open Terminal
- `cd` into the app folder
- Copy and paste: `chmod +x ./setup.sh && ./setup.sh`
- Your browser opens at `http://<yourIP>:8000/`; “Relay” turns green
- Every Session
- Open Terminal → `cd` into the app folder
- Copy and paste: `chmod +x ./start-relay.sh && ./start-relay.sh`
- Click “Detect Local IP”; the Patient link autocopies
- Stop Server
- Use “Stop Server” in the app, or press `Ctrl + C` in the relay Terminal
### What You Should See
- “Relay:” shows a green dot and “Running”
- “Detect Local IP” fills `ws://<yourIP>:8787` and copies the Patient link
- Admin page loads at `http://<yourIP>:8000/`
## Admin Options
@ -94,23 +151,48 @@ Simple browser-based tool with an Admin panel and a full-screen Patient view. Th
- 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.
### Quick Start (DoubleClick or CopyPaste)
- macOS (doubleclick): open the folder and doubleclick `start-relay.command`.
- macOS (copypaste): `chmod +x ./start-relay.sh && ./start-relay.sh`
- Windows (doubleclick): open the folder and doubleclick `start-relay.bat`.
- Windows (copypaste): `start-relay.bat`
- These start scripts autocreate a local Python virtual environment, install `websockets`, print your LAN relay URL, and run the relay on port `8787`.
### FirstTime Setup (Recommended)
- macOS/Linux:
- `chmod +x ./setup.sh && ./setup.sh`
- Windows:
- Doubleclick `setup.bat`
- What it does:
- Creates a local virtual environment and installs `websockets`
- Starts the static site server on `http://<IP>:8000/`
- Starts the WebSocket relay on `ws://<IP>:8787`
- Opens the Admin page in your browser at `http://<IP>:8000/`
### Every Session (Start/Stop)
- Start Server:
- macOS/Linux: `chmod +x ./start-relay.sh && ./start-relay.sh`
- Windows: `start-relay.bat`
- Stop Server:
- Use the Admin page “Stop Server” button, or press `Ctrl + C` in the relay terminal
### 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`)
- Quick option: doubleclick `start-relay.bat` (or run it in PowerShell/CMD)
- Manual option: `python -m pip install websockets` (or `py -m pip install websockets`), then `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`
- Quick option: doubleclick `start-relay.command` (or run `chmod +x ./start-relay.sh && ./start-relay.sh`)
- Manual option: `python3 -m pip install websockets`, then `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`
- Quick option: `chmod +x ./start-relay.sh && ./start-relay.sh`
- Manual option: `python3 -m pip install websockets`, then `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`).
@ -135,6 +217,20 @@ Simple browser-based tool with an Admin panel and a full-screen Patient view. Th
- On Windows, allow the relay through the firewall when prompted.
- You can disconnect Network Mode and continue Local Mode at any time.
## Display Options
- Same Computer (Extended Display)
- Connect a second monitor facing the patient
- Open `patient.html` in a second browser window
- Drag the Patient window to the patientfacing monitor and use browser fullscreen
- Audio unlock: have the patient do one quick gesture (mouse/key) on the Patient screen
- Pros: zero network setup, minimal friction
- Separate Computer (LAN WebSocket)
- Use the Network Mode steps above with the Python relay
- Share the short Patient link (e.g., `http://<therapist-ip>:8000/patient.html#room-1`)
- Audio unlock: one quick gesture on the Patient device; Programmatic audio is recommended
## Data Storage
- Presets: `localStorage` key `bls_presets`
@ -147,4 +243,4 @@ Simple browser-based tool with an Admin panel and a full-screen Patient view. Th
## ⚠️ Disclaimer
This tool is for research, education, and personal experimentation with bilateral stimulation. It is NOT a substitute for professional therapy, diagnosis, or treatment. Use at your own discretion.
This tool is for research, education, and personal experimentation with bilateral stimulation, or maybe the fancy lightbar broke down and you need a backup option, or maybe you sprained both your index fingers. It is NOT a substitute for professional therapy, diagnosis, or treatment. Use at your own discretion. You can freely copy or share this with blessings.

View File

@ -1,10 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bilataral Stimulation Simulator Admin</title>
<style>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bilataral Stimulation Simulator Admin</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%231f6feb'/%3E%3C/svg%3E">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #f7f7f7; color: #111; }
header { padding: 16px 20px; background: #222; color: #fff; display: flex; align-items: center; justify-content: space-between; }
header h1 { font-size: 18px; margin: 0; }
@ -30,6 +31,8 @@
#patientControls .danger { background: transparent; color: #d1242f; border: 1px solid #d1242f; padding: 6px 10px; border-radius: 6px; font-weight: 500; }
.metrics { display: flex; gap: 12px; font-size: 13px; color: #444; }
.small { font-size: 12px; color: #666; display: inline-block; min-width: 60px; }
.statusDot { display:inline-block; width:10px; height:10px; border-radius:50%; background:#d1242f; margin-right:6px; vertical-align:middle; }
.statusDot.on { background:#2da44e; }
.field { display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
.field label { min-width: 90px; }
#speed { width: 360px; }
@ -224,23 +227,7 @@
<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">
@ -272,6 +259,31 @@
<div id="sessionHistory" class="small" style="max-height:160px; overflow:auto; flex:1"></div>
</div>
<div class="small">This preview mirrors the patient's view.</div>
<div class="panel" style="margin-top:8px">
<h2>Network</h2>
<div class="row">
<label>Relay:</label>
<span id="relayStatusDot" class="statusDot"></span>
<span id="relayStatusText" class="small">Not running</span>
<button id="startRelayBtn">Copy Start Command</button>
<button id="stopRelayBtn">Stop Server</button>
</div>
<div class="row">
<label>Step 3:</label>
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" style="width:220px">
<input type="text" id="wsSession" placeholder="room-1" style="width:120px">
<button id="detectIP">Detect Local IP</button>
<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>Step 4:</label>
<input type="text" id="joinLink" readonly style="flex:1; min-width:260px">
<button id="copyJoinLink">Copy</button>
</div>
<div id="osHelp" class="small" style="margin-top:6px"></div>
</div>
</section>
</main>

View File

@ -104,7 +104,13 @@ function updateTimerLabel(){ var m = Math.max(0, parseInt(timerMinutes.value||'0
function refreshPatientUI(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); sessionCountEl.textContent = String(p.count||0); if(p.last){ var d = new Date(p.last.end); var dur = Math.round((p.last.durationSec||0)); lastSessionInfoEl.textContent = d.toLocaleString()+' • '+dur+'s' } else { lastSessionInfoEl.textContent = '—' } var host = document.getElementById('sessionHistory'); if(host){ if(!p.sessions || !p.sessions.length){ host.innerHTML = '<em>No sessions yet</em>' } else { var rows = p.sessions.slice().sort(function(a,b){ return a.start - b.start }).map(function(s, idx){ var start = new Date(s.start).toLocaleString(); var end = new Date(s.end).toLocaleString(); var dur = Math.round(s.durationSec||0); return '<div>'+String(idx+1)+'. '+start+' → '+end+' • '+dur+'s</div>' }); host.innerHTML = rows.join('') } } }
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 applyState(next, broadcast){
state = BLS.mergeState(state, next||{});
renderer.setState(state);
if(state.running){ renderer.start() } else { renderer.stop() }
if(broadcast){ chan.send(state) }
publishState()
}
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'); });
@ -162,7 +168,63 @@ function stopTimer(){ if(tickId){ clearInterval(tickId); tickId=null } recordSes
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) } });
function copyText(t, fallbackElem){
var ok=false;
if(navigator.clipboard && navigator.clipboard.writeText){
try{ navigator.clipboard.writeText(t); ok=true }catch(e){}
}
if(!ok && fallbackElem){
try{ fallbackElem.focus(); fallbackElem.select(); ok=document.execCommand('copy') }catch(e){}
}
if(!ok){
var ta=document.createElement('textarea');
ta.value=t; ta.setAttribute('readonly','');
ta.style.position='fixed'; ta.style.bottom='0'; ta.style.left='0'; ta.style.opacity='0.01';
document.body.appendChild(ta); ta.focus(); ta.select();
try{ ok=document.execCommand('copy') }catch(e){}
document.body.removeChild(ta)
}
if(!ok){ try{ alert(t) }catch(_){ } }
return ok
}
copyJoinLink.addEventListener('click', function(){ var v = joinLink.value||''; if(!v) return; var b=this; var ok=copyText(v, joinLink); var old=b.textContent; b.textContent = ok ? 'Copied!' : 'Shown'; setTimeout(function(){ b.textContent=old }, 1000) });
var detectIPBtn = document.getElementById('detectIP');
if(detectIPBtn){ detectIPBtn.addEventListener('click', function(){
function setUrl(ip){ if(!ip) return; wsUrl.value = 'ws://' + ip + ':8787'; if(!(wsSession.value||'').trim()){ wsSession.value = 'room-1' } updateJoinLink(); var v = joinLink.value||''; if(v){ var ok = copyText(v, joinLink); var b = document.getElementById('copyJoinLink'); if(b){ var old=b.textContent; b.textContent = ok? 'Copied!' : 'Copy'; setTimeout(function(){ b.textContent=old }, 1000) } } }
var host = location.hostname; if(host && host !== 'localhost' && host !== '127.0.0.1'){ setUrl(host); return }
// Attempt server-assisted detection
(function(){
var probeUrl = 'ws://' + (host || 'localhost') + ':8787'
try{
var temp = new WebSocket(probeUrl)
temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'detect_ip'})) }catch(e){} }
temp.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='detect_ip' && msg.ip){ setUrl(msg.ip); wsStatus.textContent = 'IP detected'; try{ temp.close() }catch(e){} return } }catch(e){} try{ temp.close() }catch(e){}
// Fallback to WebRTC if server didn't respond with IP
tryWebRTC()
}
temp.onerror = function(){ tryWebRTC() }
}catch(_){ tryWebRTC() }
})()
function tryWebRTC(){
var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection
if(!RTC){ wsStatus.textContent = 'Detect unavailable'; return }
try{
var pc = new RTC({iceServers: []}); pc.createDataChannel('x');
pc.onicecandidate = function(e){
if(!e || !e.candidate){ pc.close(); return }
var cand = e.candidate;
var ip = (cand && cand.address) || null;
if(!ip){
var s = cand.candidate || '';
var m = s.match(/(?:\s|\b)(\d{1,3}(?:\.\d{1,3}){3})(?:\b)/);
if(m) ip = m[1]
}
if(ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !/^169\./.test(ip)){ setUrl(ip); wsStatus.textContent = 'IP detected'; pc.close() }
};
pc.createOffer().then(function(o){ return pc.setLocalDescription(o) }).catch(function(){ wsStatus.textContent = 'Detect failed' })
}catch(_){ wsStatus.textContent = 'Detect failed' }
}
}) }
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');
@ -197,7 +259,31 @@ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded
var wsStatus = document.getElementById('wsStatus');
var joinLink = document.getElementById('joinLink');
var copyJoinLink = document.getElementById('copyJoinLink');
var startRelayBtn = document.getElementById('startRelayBtn');
var stopRelayBtn = document.getElementById('stopRelayBtn');
var relayStatusDot = document.getElementById('relayStatusDot');
var relayStatusText = document.getElementById('relayStatusText');
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 publishState(){ try{ if(wsConnected && ws){ ws.send(JSON.stringify({type:'state', sessionId: wsSid||'default', payload: BLS.loadState()})) } }catch(e){} }
function updateJoinLink(){ var sid = (wsSession.value||'').trim(); var base = location.origin + '/patient.html'; if(sid){ joinLink.value = base + '#' + encodeURIComponent(sid) } else { joinLink.value = '' } }
function renderOsHelp(){ var ua = navigator.userAgent||''; var isWin = /Windows/i.test(ua); var isMac = /Macintosh|Mac OS/i.test(ua); var isLinux = /Linux/i.test(ua) && !isWin && !isMac; var setup = (isWin? 'setup.bat' : 'chmod +x ./setup.sh && ./setup.sh'); var start = (isWin? 'start-relay.bat' : 'chmod +x ./start-relay.sh && ./start-relay.sh'); var open = (isWin? 'Open PowerShell' : isMac? 'Cmd+Space → Terminal' : 'Open Terminal'); return { setup: setup, start: start, open: open } }
(function(){ var el = document.getElementById('osHelp'); if(!el) return; var h = renderOsHelp(); var text = 'First time: ' + h.open + ', cd to the folder, run: ' + h.setup + ' • Every session: ' + h.start; el.innerText = text })();
function copyText(t){ var ok=false; if(navigator.clipboard && navigator.clipboard.writeText){ try{ navigator.clipboard.writeText(t); ok=true }catch(e){} } if(!ok){ var ta=document.createElement('textarea'); ta.value=t; ta.setAttribute('readonly',''); ta.style.position='fixed'; ta.style.bottom='0'; ta.style.left='0'; ta.style.opacity='0.01'; document.body.appendChild(ta); ta.focus(); ta.select(); try{ ok=document.execCommand('copy') }catch(e){} document.body.removeChild(ta) } if(!ok){ try{ alert(t) }catch(_){ } } return ok }
function setRelayStatus(on){ if(relayStatusDot){ relayStatusDot.classList.toggle('on', !!on) } if(relayStatusText){ relayStatusText.textContent = on? 'Running' : 'Not running' } }
(function(){ var btn = document.getElementById('wsConnect'); if(btn){ btn.disabled = true } })();
(function(){
var pollMs = 3000;
function schedule(){ setTimeout(run, pollMs) }
function run(){ checkRelayOnce().then(function(ok){ setRelayStatus(!!ok); var btn = document.getElementById('wsConnect'); if(btn){ btn.disabled = !ok } pollMs = ok ? 3000 : Math.min(Math.round(pollMs*1.5), 30000); schedule() }).catch(function(){ setRelayStatus(false); var btn = document.getElementById('wsConnect'); if(btn){ btn.disabled = true } pollMs = Math.min(Math.round(pollMs*1.5), 30000); schedule() }) }
async function checkRelayOnce(){ var host = location.hostname || 'localhost'; var url = 'ws://' + host + ':8787'; try{ return await new Promise(function(resolve){ var temp = new WebSocket(url); var done=false; var timer = setTimeout(function(){ if(!done){ resolve(false); if(temp && temp.readyState===1){ try{ temp.close() }catch(e){} } } }, 1500);
temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'ping'})) }catch(e){} };
temp.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='pong'){ done=true; clearTimeout(timer); if(temp && temp.readyState===1){ try{ temp.close() }catch(e){} } resolve(true); return } }catch(e){} resolve(false) };
temp.onerror = function(){ resolve(false) };
temp.onclose = function(){ if(!done){ resolve(false) } };
}) }catch(_){ return false } }
run()
})();
if(startRelayBtn){ startRelayBtn.addEventListener('click', function(){ var h = renderOsHelp(); var ok = copyText(h.start); var old = startRelayBtn.textContent; startRelayBtn.textContent = ok? 'Copied!' : 'Copy Start Command'; setTimeout(function(){ startRelayBtn.textContent = old }, 1200) }) }
if(stopRelayBtn){ stopRelayBtn.addEventListener('click', function(){ var host = location.hostname || 'localhost'; var url = 'ws://' + host + ':8787'; try{ var temp = new WebSocket(url); temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'shutdown'})) }catch(e){} }; setTimeout(function(){ setRelayStatus(false) }, 1500) }catch(e){} }) }
function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim()||'default'; var btn = document.getElementById('wsConnect'); if(btn && btn.disabled){ wsStatus.textContent = 'Relay not running'; return } if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } try{ ws = new WebSocket(url) }catch(e){ wsStatus.textContent = 'Error'; return } 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){} publishState() }; 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

@ -25,11 +25,34 @@ BLS<!doctype html>
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 hashSid = (location.hash||'').replace('#','')
var wsSid = qs.get('sid') || hashSid || 'default'
var wsUrl = qs.get('url') || ('ws://' + (location.hostname||'localhost') + ':8787')
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 }
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){ stopAudio(); stopProgrammatic(); renderer.stop(); return }
if(st.audioEnabled){
if(st.audioMode==='file'){
(async function(){ if(!audioFolder){ await ensureFolder() } if(st.audioName){ await loadAudioByName(st.audioName); playBuffer() } })()
} else {
stopAudio(); if(!audioUnlocked) bindUnlock(); startProgrammatic(st)
}
} else { stopAudio(); stopProgrammatic() }
renderer.start()
}
}catch(e){}
};
ws.onclose = function(){ ws = null }
}
if(wsUrl){ wsConnect() }
renderer.setState(BLS.loadState())
@ -37,7 +60,7 @@ BLS<!doctype html>
var audioCtx = null, panner = null, gain = null, source = null, audioFolder = null, audioBuffer = null
var audioUnlocked = false
var pendingProgramState = null
function bindUnlock(){ ['pointerdown','keydown','touchstart','mousemove'].forEach(function(ev){ window.addEventListener(ev, function once(){ try{ AudioEngine.ensureCtx(); audioUnlocked = true }catch(e){} if(pendingProgramState){ var st = pendingProgramState; pendingProgramState = null; startProgrammatic(st) } try{ window.removeEventListener(ev, once) }catch(_){ } }, { once:true }) }) }
function bindUnlock(){ ['pointerdown','keydown','touchstart','mousemove'].forEach(function(ev){ window.addEventListener(ev, function once(){ try{ AudioEngine.ensureCtx(); audioUnlocked = true; AudioEngine.playPing(0) }catch(e){} if(pendingProgramState){ var st = pendingProgramState; pendingProgramState = null; startProgrammatic(st) } try{ window.removeEventListener(ev, once) }catch(_){ } }, { once:true }) }) }
bindUnlock()
var progRunning = false
function stopProgrammatic(){ AudioEngine.stopNoise(); progRunning=false }

View File

@ -1,8 +1,10 @@
import asyncio
import json
import websockets
import socket
SESSIONS = {}
STOP = None
async def handler(ws):
session_id = None
@ -26,6 +28,43 @@ async def handler(ws):
await peer.send(json.dumps({'type':'state','payload':payload}))
except Exception:
pass
elif t in ('whoami','detect_ip'):
# Determine the host's primary LAN IP without external calls
ip = '127.0.0.1'
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
s.close()
except Exception:
ip = socket.gethostbyname(socket.gethostname())
try:
await ws.send(json.dumps({'type':'detect_ip','ip': ip}))
except Exception:
pass
elif t == 'shutdown':
try:
ra = ws.remote_address[0] if ws.remote_address else None
except Exception:
ra = None
local_ip = '127.0.0.1'
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
local_ip = s.getsockname()[0]
s.close()
except Exception:
try:
local_ip = socket.gethostbyname(socket.gethostname())
except Exception:
local_ip = '127.0.0.1'
if ra in ('127.0.0.1', '::1', local_ip):
try:
await ws.send(json.dumps({'type':'shutdown_ack'}))
except Exception:
pass
if STOP and not STOP.done():
STOP.set_result(True)
elif t == 'ping':
await ws.send(json.dumps({'type':'pong'}))
finally:
@ -33,9 +72,11 @@ async def handler(ws):
SESSIONS[session_id].discard(ws)
async def main():
global STOP
STOP = asyncio.Future()
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()
await STOP
if __name__ == '__main__':
asyncio.run(main())

25
setup.bat Normal file
View File

@ -0,0 +1,25 @@
@echo off
setlocal enabledelayedexpansion
cd /d "%~dp0"
set USEPY=
where py >nul 2>&1 && set USEPY=py
if not defined USEPY where python >nul 2>&1 && set USEPY=python
if not defined USEPY echo Python not found & exit /b 1
if exist ".venv" (
echo Using existing venv
) else (
if "%USEPY%"=="py" (
%USEPY% -3 -m venv .venv
) else (
%USEPY% -m venv .venv
)
)
set VPY=.\.venv\Scripts\python
"%VPY%" -m pip install -U pip websockets
for /f "delims=" %%I in ('"%VPY%" -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(('8.8.8.8',80));print(s.getsockname()[0]);s.close()"') do set IP=%%I
if not defined IP set IP=localhost
rem Start static server if not running
netstat -ano | findstr ":8000" >nul || start /min cmd /c "%VPY% -m http.server 8000"
start "" http://%IP%:8000/
echo Relay URL: ws://%IP%:8787
"%VPY%" server.py

28
setup.sh Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if command -v python3 >/dev/null 2>&1; then PY=python3; elif command -v python >/dev/null 2>&1; then PY=python; else echo "Python not found"; exit 1; fi
if [ ! -d ".venv" ]; then "$PY" -m venv .venv; fi
VENV_PY="./.venv/bin/python"
"$VENV_PY" -m pip install -U pip websockets
IP="$($VENV_PY - <<'PYCODE'
import socket
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
try:
s.connect(('8.8.8.8',80))
print(s.getsockname()[0])
except Exception:
print('localhost')
finally:
s.close()
PYCODE
)"
# Start static server if not running
if ! lsof -ti:8000 >/dev/null 2>&1; then nohup "$VENV_PY" -m http.server 8000 >/dev/null 2>&1 & fi
URL="http://$IP:8000/"
case "$(uname -s)" in
Darwin) open "$URL" ;;
Linux) command -v xdg-open >/dev/null 2>&1 && xdg-open "$URL" || true ;;
esac
echo "Relay URL: ws://$IP:8787"
exec "$VENV_PY" server.py

23
start-relay.bat Normal file
View File

@ -0,0 +1,23 @@
@echo off
setlocal enabledelayedexpansion
cd /d "%~dp0"
set USEPY=
where py >nul 2>&1 && set USEPY=py
if not defined USEPY where python >nul 2>&1 && set USEPY=python
if not defined USEPY echo Python not found & exit /b 1
if exist ".venv" (
echo Using existing venv
) else (
if "%USEPY%"=="py" (
%USEPY% -3 -m venv .venv
) else (
%USEPY% -m venv .venv
)
)
set VPY=.\.venv\Scripts\python
"%VPY%" -m pip install -U pip websockets
for /f "delims=" %%I in ('"%VPY%" -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(('8.8.8.8',80));print(s.getsockname()[0]);s.close()"') do set IP=%%I
if not defined IP set IP=localhost
netstat -ano | findstr ":8000" >nul || start /min cmd /c "%VPY% -m http.server 8000"
echo Relay URL: ws://%IP%:8787
"%VPY%" server.py

4
start-relay.command Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
cd "$(dirname "$0")"
chmod +x ./start-relay.sh
./start-relay.sh

22
start-relay.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if command -v python3 >/dev/null 2>&1; then PY=python3; elif command -v python >/dev/null 2>&1; then PY=python; else echo "Python not found"; exit 1; fi
if [ ! -d ".venv" ]; then "$PY" -m venv .venv; fi
VENV_PY="./.venv/bin/python"
"$VENV_PY" -m pip install -U pip websockets
if ! lsof -ti:8000 >/dev/null 2>&1; then nohup "$VENV_PY" -m http.server 8000 >/dev/null 2>&1 & fi
IP="$($VENV_PY - <<'PYCODE'
import socket
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
try:
s.connect(('8.8.8.8',80))
print(s.getsockname()[0])
except Exception:
print('localhost')
finally:
s.close()
PYCODE
)"
echo "Relay URL: ws://$IP:8787"
exec "$VENV_PY" server.py