diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 58eede0..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f2e848 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f809a7f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 John King (monsterrobotsoft) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 30cec3f..4cf8a93 100644 --- a/README.md +++ b/README.md @@ -1,327 +1,115 @@ -# Bilateral Stimulation Simulator +# backnforth — Bilateral Stimulation Simulator -Simple browser-based tool with a therapist panel and a full-screen Patient view. The therapist controls everything; the Patient views bilateral stimulus as per therapist settings. +A browser-based bilateral stimulation (BLS) tool: a therapist control panel +and a full-screen patient view, with visual, auditory, and mixed stimulus. +Built to run on whatever hardware exists — an old laptop, a donated desktop, a +second-hand phone as the patient screen. No accounts, no cloud, no installs, +works fully offline. If the fancy lightbar broke down, or there never was a +fancy lightbar, this is the backup plan. -## User Guide +## ⚠️ Safety first -### Getting Started -- Open the app and stay on the Therapist (Admin) page; the Patient view mirrors whatever you set. -- For one computer with two screens: open the Patient view and move it to the patient-facing display, then go fullscreen. -- For two computers: you can also use Network Mode (details below in Nerd Stuff), but the simple two‑screen setup is usually easiest. -- Audio may need a single click in the Patient view to unlock — this is normal browser behavior. +- **Photosensitivity:** moving and pulsing visuals can trigger seizures in + people with photosensitive epilepsy. Ask about seizure history before use, + keep Glow Pulse rate low (the app caps it below the common trigger band), + and stop immediately if the viewer feels unwell. +- **Clinical use:** bilateral stimulation can surface intense memories and + emotions. This tool assists a trained clinician — it is not therapy by + itself and is not for unsupervised trauma processing. +- **Privacy:** patient records and notes are stored unencrypted in the + browser. Use initials or a code instead of full names, and treat the + computer itself as part of the clinical record. There are Export/Wipe + buttons in Patient Management. -### Set Up the Visuals -- Shape: choose Circle, Square, or Triangle. -- Size: drag the size slider to make the shape larger or smaller. -- Speed: use the speed slider; the number shows how fast the shape moves. - - Direction: pick Left↔Right (horizontal), Up↕Down (vertical), or Diagonal. -- Color: pick a shape color and a background color; use the swatches or the color picker. - - Easing and Edge Pause: choose smoother movement (Ease In‑Out) and optional short pauses at the edges. - - Vertical Position: set a baseline (0–100%) for where the motion track sits. - - Vertical Oscillation: toggle gentle up/down motion around the chosen baseline. - - Extras: enable Glow Pulse, Wiggle, Ramp Speed. +## Quick start -### Sound Options -- Programmatic Sounds: built‑in cues like Pink Noise, Hybrid (noise plus edge hits), Click, Ping, Woodblock, Bell, Bass, Kick, Snare, Hi‑Hat, Sweep, Zap, Bubble. -- Pan Sync: optionally pan sound left/right in time with the movement. -- Volume: set an overall volume; use Test Sound to preview. -#### Tone Mode (ADSR) -- Frequency: pitch of the tone in Hertz (e.g., 440 Hz). -- Duration: how long the tone plays on each hit (milliseconds). -- Attack: time to rise from silence to full volume. -- Decay: time to fall from full volume down to the sustain level. -- Sustain: steady level held during the middle of the tone (0.0–1.0). -- Release: time to fade from the sustain level back to silence. -- Pan per hit: tones pan left/right to match where the shape hits the edges. -- Pan Sync (continuous): for pink noise, enables smooth left/right panning following movement. -- Pan Rate: speed of continuous panning when Pan Sync is on. +**One computer, two screens (easiest — nothing to run):** +1. Open `index.html` in a browser. This is the therapist panel. +2. Click **Open Patient View**, drag that window to the patient-facing + display, make it fullscreen. +3. One click on the patient view unlocks audio (browser rule). -### Use Your Own Audio -- Choose Folder: select a folder of audio files (MP3/WAV). Then pick a track from the list. -- Play/Pan: the app can pan the file left/right to match movement. -- Tip: the first time you use audio, click once in the Patient view to unlock sound. +**Two devices on the same Wi-Fi:** +1. Double-click `start-relay.command` (Mac) or `start-relay.bat` (Windows), + or run `./start-relay.sh` (Linux). Needs only Python 3 — no pip, no + installs, no internet. It serves the app and the session relay in one + process and opens the therapist page for you. +2. Click **Detect Local IP** — the patient join link is auto-copied. +3. Open that link on the patient device; one tap unlocks audio. +4. Allow the firewall prompt (private networks) if one appears. -### Patients and Sessions -- Save Patient: enter a name and save. Patients are listed for quick loading later. -- Session History: each session records start/end time and duration automatically for the loaded patient. -- Session Notes: pick a session from the list, edit notes, and save; notes stay with the session. -- Manage: load an existing patient, clear a patient’s sessions, or delete a patient. -- Storage: patient info stays on your computer in the browser (no cloud backend). +## The therapist panel -### Record Movement Changes (Research/Teaching Tool) -- Start Recording: begin capturing every change you make (speed, size, direction, color, etc.). -- Stop & Save: end recording and name it; it appears in your recording list. -- Replay: select a recording and play it back to reproduce the exact sequence of changes. -- Export: save a recording as a JSON file — handy to share or archive. -- Import: load a previously exported JSON file back into your recording list. -- Clear/Delete: remove one recording or clear the entire list. +### Visuals +- **Shape**: circle, square, triangle · size slider · shape/background colors + with swatches or custom picker. +- **Motion**: speed (px/s) with 1–4× boost, direction (left↔right, up↕down, + diagonals), easing (linear or ease-in-out), edge pause, vertical position + and gentle vertical oscillation, wiggle, ramp-up over 1–60 s. +- **Glow Pulse**: optional pulsing glow; rate capped at 2.5 Hz because the + photosensitive-epilepsy trigger band starts around 3 Hz. + +### Sound +- **Programmatic presets**: pink noise, hybrid (noise + edge cues), click, + ping, woodblock, bell, bass, kick, snare, hi-hat, sweep, zap, bubble. +- **Tone designer**: frequency, duration, and full ADSR envelope, with tone + presets from warm-low to crisp. +- **Panning**: discrete sounds pan to the edge the shape hits; pink noise can + pan continuously (Pan Sync) or stay centered. +- **Your own audio**: pick a folder of MP3/WAV files; playback pans with the + movement. + +### Patients and sessions +- Save patients (initials or codes — see Safety), auto-recorded session + history with durations, per-session notes. +- **Export All Patient Data** downloads a JSON backup; **Wipe All Patient + Data** clears everything from the browser (two confirmations). + +### Recording and replay (research/teaching) +- Record every settings change during a session, replay it exactly, and + import/export recordings as JSON. ### Presets -- Save a Preset: name the current setup (visuals and audio) and save. -- Apply: pick a preset to immediately apply all saved settings. -- Delete: remove a preset you no longer need. -- Import/Export: backup or share all presets as a single JSON file. +- Save/apply/delete full setups (visuals + audio); import/export all presets + as one JSON file. -### Controllers and Shortcuts -- Keyboard Mapping: map keys to actions like Start/Stop, Speed Up/Down, Volume Up/Down, Toggle Audio, Glow/Wiggle, Lock/Unlock, Record Start/Stop, Replay, and Next Shape/Direction/Easing/Sound. -- MIDI Controllers: connect a MIDI device and map buttons/knobs to the same actions; the mapping panel shows what’s currently assigned. -- HID Dial/Knob: connect a hardware dial via the “Connect HID” button, then turn the dial to change speed; adjust sensitivity if the dial feels too strong or too subtle. -- Lock/Unlock: press `L` to lock the interface (prevents accidental clicks). Hold `U` to unlock; the banner shows the lock status. +### Controllers +- Map keyboard keys, MIDI notes/CCs, or a WebHID dial to actions: start/stop, + speed, volume, toggles, next shape/direction/sound, lock. +- Press `L` to lock the interface against stray clicks; hold `U` to unlock. -### Tips and Safety -- Go gently: start slow and simple. Adjust based on comfort. -- Pause anytime: you can stop motion and sound immediately. -- Everything is local: patients, presets, and recordings are stored on your device. +## Network mode details -## Nerd Stuff +- The relay (`server.py`, Python 3 stdlib only) serves the therapist page on + `http://:8000/` and the WebSocket relay on `ws://:8787`. +- **Session IDs are random by default** (e.g. `room-k3x9q2f1`). Anyone on the + same network who knows a session ID can join it and control what the + patient sees — leave the random ID alone and share only the join link. +- Stop the server with the **Stop Server** button (works from the therapist + machine only) or `Ctrl+C` in the terminal. +- A manual peer-to-peer WebRTC mode exists for networks where the relay is + blocked: create an offer on the admin, paste it on the patient, paste the + answer back. -## How It Works +## Data storage -- 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 Wi‑Fi/LAN: start a tiny local server on the therapist’s 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 +Everything stays in the browser on the therapist machine: -## Super Simple Guide +- Settings: `localStorage` key `bls_state` · presets: `bls_presets` · + patients/sessions: `bls_patients` · key mappings: `bls_mappings` +- Recordings: IndexedDB `bls_rec_db` (legacy `bls_recordings` auto-migrates) -### Windows -- First‑Time Setup - - Double‑click `setup.bat` - - Allow the Windows Defender Firewall prompt (Private networks) - - Your browser opens at `http://:8000/`; “Relay” turns green -- Every Session - - Double‑click `start-relay.bat` - - Click “Detect Local IP” in the app; the Patient link auto‑copies -- Stop Server - - Use “Stop Server” in the app, or press `Ctrl + C` in the relay window +Nothing is sent anywhere except live stimulus state to the patient view. -### macOS -- First‑Time Setup - - Press `Cmd + Space` → type `Terminal` → press `Enter` - - `cd` into the folder where you unzipped the app eg 'cd documents/bls-sim' - - Copy and paste: `chmod +x ./setup.sh && ./setup.sh` - - Allow the macOS firewall prompt (Private networks) - - Your browser opens at `http://: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 auto‑copies -- Stop Server - - Use “Stop Server” in the app, or press `Ctrl + C` in the relay Terminal +## License -### Linux -- First‑Time 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://: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 auto‑copies -- 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://:8787` and copies the Patient link -- Admin page loads at `http://:8000/` - -## Admin Options - -- Shape - - Options: Circle, Square, Triangle -- Shape Color - - Picker + presets: Green, Blue, Yellow, Red, White -- Background Color - - Picker + presets: Green, Blue, Yellow, Red, White -- Direction - - Left↔Right, Up↕Down, Diagonal ↘, Diagonal ↗ -- Speed - - Range: 50–600 px/s - - Speed Boost: 1×, 2×, 3×, 4× multiplier -- Size - - Range: 10–100 px -- Vertical Position - - Range: 0–100% (Reset to 50%) -- Glow Pulse - - Enable/Disable - - Glow Radius: 0–200% - - Glow Rate: 0.1–3.0 Hz -- Ramp Speed - - Enable/Disable, Seconds: 1–60 - - When disabled, motion uses constant speed (Linear) -- Smoothness / Easing - - Linear (constant speed) - - Ease In-Out (slows at edges) -- Edge Pause - - Range: 0–150 ms -- Wiggle - - Enable/Disable, Amplitude: 0–10 px - -### Audio - -- Audio Mode - - Programmatic, From Files, Off -- Programmatic Presets - - Pink Noise, Hybrid (noise + edge cues), Click, Ping, Woodblock - - Bell, Bass, Kick, Snare, Hi-Hat, Sweep, Zap, Bubble -- Pan Sync to Movement - - Toggle on/off -- Pan Rate (for continuous panning) - - Range: 0.1–2.0 Hz -- Cue Rate (Hybrid) - - Range: 0.1–3.0 Hz -- Volume - - Range: 0–100% -- Test Sound - - Plays the selected programmatic preset briefly - -### File Audio - -- Choose Folder, select track from dropdown (MP3/WAV) - -### Presets (Admin) - -- Save: Enter name and save current state -- Apply: Select from list and apply -- Delete: Remove a preset -- Import/Export: JSON of all presets - -### Patient Management - -- Patient Name: Save patient -- Load Patient: Select from list -- Clear Sessions: Reset history for loaded patient -- Delete Patient: Remove patient record -- Session History: Auto-listed with start/end time and duration - -### Session Controls (Preview Panel) - -- Timer (minutes) -- Start Session / End Session -- Status + Elapsed time - -## Patient View - -- Full-screen canvas with motion synced to Admin -- Audio unlock: most browsers require a user gesture (e.g., click or key press) before audio can play - -## How to Run - -- Open `index.html` and use the Admin panel -- Click “Open Patient View” to launch `patient.html` in a separate tab/window - -### Controllers (Tech) -- Keyboard: uses standard keydown events and a mapping registry stored in `localStorage` (`bls_mappings`). -- MIDI: uses Web MIDI (`navigator.requestMIDIAccess`) and maps Notes/CCs to actions. -- HID: uses WebHID (`navigator.hid.requestDevice`) and listens to `inputreport`; example HID dial adjusts speed with sensitivity scaling. -- Gamepad: not implemented. - -## 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. - -### Quick Start (Double‑Click or Copy‑Paste) -- macOS (double‑click): open the folder and double‑click `start-relay.command`. -- macOS (copy‑paste): `chmod +x ./start-relay.sh && ./start-relay.sh` -- Windows (double‑click): open the folder and double‑click `start-relay.bat`. -- Windows (copy‑paste): `start-relay.bat` -- These start scripts auto‑create a local Python virtual environment, install `websockets`, print your LAN relay URL, and run the relay on port `8787`. - -### First‑Time Setup (Recommended) -- macOS/Linux: - - `chmod +x ./setup.sh && ./setup.sh` -- Windows: - - Double‑click `setup.bat` -- What it does: - - Creates a local virtual environment and installs `websockets` - - Starts the static site server on `http://:8000/` - - Starts the WebSocket relay on `ws://:8787` - - Opens the Admin page in your browser at `http://: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 - -### One‑Time Install (WebSocket Relay) -- Windows: - - Press `Windows` key → type `PowerShell` → `Enter` - - Check Python: `python --version` (or `py --version`) - - Quick option: double‑click `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` - - Quick option: double‑click `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` - - Quick option: `chmod +x ./start-relay.sh && ./start-relay.sh` - - Manual option: `python3 -m pip install websockets`, then `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. - -## 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 patient‑facing 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://: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` -- Patients: `localStorage` key `bls_patients` -- App State (current settings): `localStorage` key `bls_state` -- Recordings: IndexedDB database `bls_rec_db`, store `recordings` (legacy `localStorage` key `bls_recordings` may exist and is auto‑migrated) - -## Notes - -- If Audio is enabled and Programmatic mode is selected, the Patient tab may need one user interaction to unlock audio -- Speed Boost multiplies the slider value for effective motion speed +MIT — see [LICENSE](LICENSE). You can freely copy, hack, and share this with +blessings. ## ⚠️ Disclaimer -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. +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. diff --git a/index.html b/index.html index b55fddb..2dbe776 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,29 @@ + +

Bilateral Stimulation Simulator

@@ -154,7 +177,8 @@
- + +
@@ -506,9 +530,12 @@
- +
+
+ 🔒 Stored unencrypted in this browser — use initials or a code, never a full name. +
@@ -543,6 +570,11 @@
+ +
+ + +
@@ -594,7 +626,7 @@
- +
diff --git a/js/admin.js b/js/admin.js index 9c86c61..195355f 100644 --- a/js/admin.js +++ b/js/admin.js @@ -97,6 +97,8 @@ var endBtn = document.getElementById('endSession'); var sessionNotesEl = document.getElementById('sessionNotes'); var saveSessionNotesBtn = document.getElementById('saveSessionNotes'); var deleteSessionSingleBtn = document.getElementById('deleteSession'); +function esc(s){ return String(s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c] }) } +function randSid(){ var a = new Uint32Array(2); (window.crypto||{getRandomValues:function(x){ for(var i=0;i'+n+'' }).join(''); var sel = document.getElementById('patientList'); if(sel){ sel.innerHTML = html } + function refreshPatientList(){ var m = loadPatients(); var names = Object.keys(m).sort(); var html = names.map(function(n){ return '' }).join(''); var sel = document.getElementById('patientList'); if(sel){ sel.innerHTML = html } } function loadPatients(){ try{ var m = JSON.parse(localStorage.getItem('bls_patients')||'null'); return m&&typeof m==='object'? m : {} }catch(e){ return {} } } function savePatients(m){ try{ localStorage.setItem('bls_patients', JSON.stringify(m)) }catch(e){} } @@ -128,7 +130,7 @@ function updateTimerLabel(){ var m = Math.max(0, parseInt(timerMinutes.value||'0 function deletePatient(name){ var n = (name||'').trim()||'Unnamed'; var m = loadPatients(); if(m[n]){ delete m[n]; savePatients(m) } } 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 = 'No sessions yet' } 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 '
'+String(idx+1)+'. '+start+' → '+end+' • '+dur+'s
' }); host.innerHTML = rows.join('') } } if(sessionSelect){ var opts = (!p.sessions || !p.sessions.length)? [] : p.sessions.slice().sort(function(a,b){ return a.start - b.start }).map(function(s, idx){ var start = new Date(s.start).toLocaleString(); return { i: idx, label: String(idx+1)+'. '+start } }); var html = opts.map(function(o){ return '' }).join(''); sessionSelect.innerHTML = html; if(opts.length){ sessionSelect.value = String(opts[opts.length-1].i); var s = p.sessions[opts[opts.length-1].i]; if(sessionNotesEl){ sessionNotesEl.value = s.notes||'' } } else { if(sessionNotesEl){ sessionNotesEl.value = '' } } } } 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, notes: '' }); p.count = (p.count||0)+1; p.last = { start: st.startedAt, end: end, durationSec: durationSec, notes: '' }; setPatient(p); refreshPatientUI() } - function refreshPresetList(){ var names = BLS.listPresets(); var html=''; for(var i=0;i'+n+'' } presetList.innerHTML = html } + function refreshPresetList(){ var names = BLS.listPresets(); var html=''; for(var i=0;i'+esc(n)+'' } presetList.innerHTML = html } function applyState(next, broadcast){ var prevRunning = state && !!state.running; // Explicitly persist=true for Admin actions @@ -255,8 +257,12 @@ function recordSession(){ var n = (patientName.value||'').trim()||'Unnamed'; var if(deletePatientBtn){ deletePatientBtn.addEventListener('click', function(){ var sel = document.getElementById('patientList'); var n = (sel && sel.value) ? sel.value : (patientName.value||'').trim(); if(!n){ try{ alert('Please select or enter a patient name') }catch(_){ } return } if(!confirm('Delete patient "'+n+'" and all their data?')) return; deletePatient(n); patientName.value=''; refreshPatientList(); refreshPatientUI() }) } var loadPatientBtn = document.getElementById('loadPatient'); if(loadPatientBtn){ loadPatientBtn.addEventListener('click', function(){ var sel = document.getElementById('patientList'); if(!sel) return; var n = sel.value; if(!n) return; patientName.value = n; refreshPatientUI() }) } + var exportAllBtn = document.getElementById('exportAllData'); + if(exportAllBtn){ exportAllBtn.addEventListener('click', function(){ var payload = JSON.stringify({ patients: loadPatients(), exportedAt: new Date().toISOString() }, null, 2); var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([payload], {type:'application/json'})); a.download = 'bls-patients.json'; a.click(); URL.revokeObjectURL(a.href) }) } + var wipeAllBtn = document.getElementById('wipeAllData'); + if(wipeAllBtn){ wipeAllBtn.addEventListener('click', function(){ if(!confirm('Wipe ALL patient records and session notes from this browser?')) return; if(!confirm('This cannot be undone. Export first if you need a copy. Really wipe?')) return; try{ localStorage.removeItem('bls_patients') }catch(_){ } patientName.value=''; refreshPatientList(); refreshPatientUI() }) } var speedBoost = document.getElementById('speedBoost'); - if(speedBoost){ speedBoost.addEventListener('click', function(e){ var m = e.target.dataset.mult; if(!m) return; var mult = parseInt(m,10)||1; var btns = speedBoost.querySelectorAll('button'); for(var i=0;i 0){ diff --git a/js/patient.js b/js/patient.js index 6455b4f..11523b3 100644 --- a/js/patient.js +++ b/js/patient.js @@ -258,10 +258,12 @@ } var progRunning = false; + var noiseSig = null; // signature of the running noise source, so slider drags don't restart it function stopProgrammatic(){ AudioEngine.stopNoise(); progRunning = false; + noiseSig = null; } function startProgrammatic(st){ @@ -276,19 +278,20 @@ // Clear previous update handlers related to audio renderer.setOnUpdate(onRenderUpdate); - if(st.programPreset === 'pink'){ - if(st.syncPan){ - // We handle panning in onRenderUpdate - AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate || 0.5)); + if(st.programPreset === 'pink' || st.programPreset === 'hybrid'){ + var sig = [st.programPreset, !!st.syncPan, st.panRate, st.cueRate].join('|'); + if(progRunning && sig === noiseSig){ return; } // only volume changed — source keeps playing + noiseSig = sig; + if(st.programPreset === 'pink'){ + AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate || 0.5), st.syncPan !== false); } else { - AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate || 0.5)); + AudioEngine.startHybrid(Math.max(0.1, st.cueRate || 1.2)); } progRunning = true; - } else if(st.programPreset === 'hybrid'){ - AudioEngine.startHybrid(Math.max(0.1, st.cueRate || 1.2)); - progRunning = true; } else { - // Discrete sounds on edges + // Discrete sounds on edges; kill any noise source left over from a preset switch + AudioEngine.stopNoise(); + noiseSig = null; renderer.setOnUpdate(function(info){ onRenderUpdate(info); // Call the base update for visual/panner sync diff --git a/server.py b/server.py index 4008ffc..83b0a02 100644 --- a/server.py +++ b/server.py @@ -1,82 +1,246 @@ -import asyncio +"""backnforth server — static site + WebSocket relay, Python stdlib only. + + python3 server.py [--no-browser] + +Serves the therapist page on http://:8000/ and the session relay on +ws://:8787. No pip installs, no virtualenv — any Python 3.7+ works, +including old machines that will never see a package index. +""" +import base64 +import hashlib import json -import websockets +import os import socket +import socketserver +import struct +import sys +import threading +import webbrowser +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer -SESSIONS = {} -STOP = None +HTTP_PORT = 8000 +WS_PORT = 8787 +_WS_GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11' -async def handler(ws): - session_id = None +SESSIONS = {} # session id -> set of live WSHandler +LOCK = threading.Lock() +_SERVERS = [] + + +def local_ip(): 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 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: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 80)) + ip = s.getsockname()[0] + s.close() + return ip + except OSError: + try: + return socket.gethostbyname(socket.gethostname()) + except OSError: + return '127.0.0.1' + + +def shutdown_all(): + for srv in _SERVERS: + try: + srv.shutdown() + except Exception: + pass + + +class WSHandler(socketserver.BaseRequestHandler): + """Minimal RFC6455 server: unfragmented text frames, ping/pong, close. + + ponytail: no fragmentation/binary support — the state payloads are a few + hundred bytes of JSON and browsers never fragment at that size. + """ + + def setup(self): + self.session_id = None + self.send_lock = threading.Lock() + + def handle(self): + if not self._handshake(): + return + try: + while True: + msg = self._read_message() + if msg is None: + break + self._dispatch(msg) + except (ConnectionError, OSError): + pass + + def finish(self): + if self.session_id: + with LOCK: + SESSIONS.get(self.session_id, set()).discard(self) + + # ---- relay protocol (same JSON messages as the old websockets server) ---- + + def _dispatch(self, text): + try: + data = json.loads(text) + except ValueError: + return + t = data.get('type') + if t == 'join': + self.session_id = data.get('sessionId') or 'default' + with LOCK: + SESSIONS.setdefault(self.session_id, set()).add(self) + self._send_json({'type': 'joined', 'sessionId': self.session_id}) + elif t in ('state', 'patientViewport'): + sid = data.get('sessionId') or self.session_id or 'default' + out = json.dumps({'type': t, 'payload': data.get('payload')}) + with LOCK: + peers = list(SESSIONS.get(sid, ())) + for peer in peers: + if peer is not self: 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'})) + peer._send_text(out) except Exception: pass - if STOP and not STOP.done(): - STOP.set_result(True) - 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) + elif t in ('whoami', 'detect_ip'): + self._send_json({'type': 'detect_ip', 'ip': local_ip()}) + elif t == 'ping': + self._send_json({'type': 'pong'}) + elif t == 'shutdown': + if self.client_address[0] in ('127.0.0.1', '::1', local_ip()): + self._send_json({'type': 'shutdown_ack'}) + threading.Thread(target=shutdown_all, daemon=True).start() + + # ---- websocket plumbing ---- + + def _handshake(self): + self.request.settimeout(10) + data = b'' + while b'\r\n\r\n' not in data: + chunk = self.request.recv(4096) + if not chunk or len(data) > 65536: + return False + data += chunk + headers = {} + for line in data.split(b'\r\n')[1:]: + if b':' in line: + k, v = line.split(b':', 1) + headers[k.strip().lower()] = v.strip() + key = headers.get(b'sec-websocket-key') + if not key: + return False + accept = base64.b64encode(hashlib.sha1(key + _WS_GUID).digest()).decode() + self.request.sendall(( + 'HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n' + ).encode()) + self.request.settimeout(None) + return True + + def _recv_exact(self, n): + buf = b'' + while len(buf) < n: + chunk = self.request.recv(n - len(buf)) + if not chunk: + return None + buf += chunk + return buf + + def _read_message(self): + while True: + head = self._recv_exact(2) + if head is None: + return None + b1, b2 = head + opcode = b1 & 0x0F + length = b2 & 0x7F + if length == 126: + ext = self._recv_exact(2) + if ext is None: + return None + length = struct.unpack('>H', ext)[0] + elif length == 127: + ext = self._recv_exact(8) + if ext is None: + return None + length = struct.unpack('>Q', ext)[0] + if length > 1_000_000: + return None + mask = self._recv_exact(4) if (b2 & 0x80) else b'\x00\x00\x00\x00' + if mask is None: + return None + payload = self._recv_exact(length) if length else b'' + if payload is None: + return None + payload = bytes(c ^ mask[i % 4] for i, c in enumerate(payload)) + if opcode == 0x1: # text + try: + return payload.decode('utf-8') + except UnicodeDecodeError: + return None + elif opcode == 0x9: # ping -> pong + self._send_frame(0xA, payload) + elif opcode == 0x8: # close + try: + self._send_frame(0x8, b'') + except Exception: + pass + return None + # anything else (binary/continuation): skip + + def _send_frame(self, opcode, payload): + n = len(payload) + head = bytes([0x80 | opcode]) + if n < 126: + head += bytes([n]) + elif n < 65536: + head += bytes([126]) + struct.pack('>H', n) + else: + head += bytes([127]) + struct.pack('>Q', n) + with self.send_lock: + self.request.sendall(head + payload) + + def _send_text(self, text): + self._send_frame(0x1, text.encode('utf-8')) + + def _send_json(self, obj): + self._send_text(json.dumps(obj)) + + +class _QuietHTTP(SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + +def main(): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + + socketserver.ThreadingTCPServer.allow_reuse_address = True + ThreadingHTTPServer.allow_reuse_address = True + httpd = ThreadingHTTPServer(('0.0.0.0', HTTP_PORT), _QuietHTTP) + wsd = socketserver.ThreadingTCPServer(('0.0.0.0', WS_PORT), WSHandler) + httpd.daemon_threads = True + wsd.daemon_threads = True + _SERVERS.extend([httpd, wsd]) + + ip = local_ip() + print('backnforth running:') + print(' Therapist page: http://%s:%d/' % (ip, HTTP_PORT)) + print(' Session relay: ws://%s:%d' % (ip, WS_PORT)) + print('Press Ctrl+C to stop.') + + threading.Thread(target=wsd.serve_forever, daemon=True).start() + if '--no-browser' not in sys.argv: + try: + webbrowser.open('http://%s:%d/' % (ip, HTTP_PORT)) + except Exception: + pass + try: + httpd.serve_forever() + except KeyboardInterrupt: + print('\nStopped.') -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 STOP if __name__ == '__main__': - asyncio.run(main()) + main() diff --git a/setup.bat b/setup.bat deleted file mode 100644 index f6d26c8..0000000 --- a/setup.bat +++ /dev/null @@ -1,25 +0,0 @@ -@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 diff --git a/setup.sh b/setup.sh deleted file mode 100644 index 717296c..0000000 --- a/setup.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/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 diff --git a/start-relay.bat b/start-relay.bat index 3881054..6b4c91d 100644 --- a/start-relay.bat +++ b/start-relay.bat @@ -1,23 +1,4 @@ -@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 +@echo off +rem backnforth: static site + relay in one process, stdlib only - nothing to install +cd /d "%~dp0" +py server.py %* 2>nul || python server.py %* diff --git a/start-relay.command b/start-relay.command old mode 100644 new mode 100755 index 9257acd..bea178a --- a/start-relay.command +++ b/start-relay.command @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh +# backnforth: static site + relay in one process, stdlib only - nothing to install cd "$(dirname "$0")" -chmod +x ./start-relay.sh -./start-relay.sh +exec python3 server.py "$@" diff --git a/start-relay.sh b/start-relay.sh index a676c21..bea178a 100755 --- a/start-relay.sh +++ b/start-relay.sh @@ -1,22 +1,4 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/bin/sh +# backnforth: static site + relay in one process, stdlib only - nothing to install 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 +exec python3 server.py "$@"