Safety, privacy, session security, bug fixes, zero-install server
- First-run safety notice (photosensitivity, clinical use, privacy); glow rate capped at 2.5Hz below the photosensitive trigger band - Patient data: initials/code hint, Export All / Wipe All buttons - Relay sessions get crypto-random room ids instead of guessable room-1 - Fix: Pan Sync off now actually stops pink-noise panning - Fix: slider drags no longer audibly restart the noise source - Fix: switching pink->discrete preset no longer leaves noise running - Fix: Speed Boost no longer compounds into stored speed across reloads - Fix: patient/preset names HTML-escaped in lists - Fix: patientViewport now relays over LAN (old server dropped it) - server.py rewritten stdlib-only: static site + WS relay in one process, no pip/venv; setup.sh/bat deleted, start scripts are now 3 lines - MIT LICENSE added; README rewritten with safety-first quick start Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e3396e6084
commit
4a46417cba
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -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.
|
||||||
398
README.md
398
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
|
- **Photosensitivity:** moving and pulsing visuals can trigger seizures in
|
||||||
- Open the app and stay on the Therapist (Admin) page; the Patient view mirrors whatever you set.
|
people with photosensitive epilepsy. Ask about seizure history before use,
|
||||||
- For one computer with two screens: open the Patient view and move it to the patient-facing display, then go fullscreen.
|
keep Glow Pulse rate low (the app caps it below the common trigger band),
|
||||||
- For two computers: you can also use Network Mode (details below in Nerd Stuff), but the simple two‑screen setup is usually easiest.
|
and stop immediately if the viewer feels unwell.
|
||||||
- Audio may need a single click in the Patient view to unlock — this is normal browser behavior.
|
- **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
|
## Quick start
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Sound Options
|
**One computer, two screens (easiest — nothing to run):**
|
||||||
- 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.
|
1. Open `index.html` in a browser. This is the therapist panel.
|
||||||
- Pan Sync: optionally pan sound left/right in time with the movement.
|
2. Click **Open Patient View**, drag that window to the patient-facing
|
||||||
- Volume: set an overall volume; use Test Sound to preview.
|
display, make it fullscreen.
|
||||||
#### Tone Mode (ADSR)
|
3. One click on the patient view unlocks audio (browser rule).
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Use Your Own Audio
|
**Two devices on the same Wi-Fi:**
|
||||||
- Choose Folder: select a folder of audio files (MP3/WAV). Then pick a track from the list.
|
1. Double-click `start-relay.command` (Mac) or `start-relay.bat` (Windows),
|
||||||
- Play/Pan: the app can pan the file left/right to match movement.
|
or run `./start-relay.sh` (Linux). Needs only Python 3 — no pip, no
|
||||||
- Tip: the first time you use audio, click once in the Patient view to unlock sound.
|
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
|
## The therapist panel
|
||||||
- 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).
|
|
||||||
|
|
||||||
### Record Movement Changes (Research/Teaching Tool)
|
### Visuals
|
||||||
- Start Recording: begin capturing every change you make (speed, size, direction, color, etc.).
|
- **Shape**: circle, square, triangle · size slider · shape/background colors
|
||||||
- Stop & Save: end recording and name it; it appears in your recording list.
|
with swatches or custom picker.
|
||||||
- Replay: select a recording and play it back to reproduce the exact sequence of changes.
|
- **Motion**: speed (px/s) with 1–4× boost, direction (left↔right, up↕down,
|
||||||
- Export: save a recording as a JSON file — handy to share or archive.
|
diagonals), easing (linear or ease-in-out), edge pause, vertical position
|
||||||
- Import: load a previously exported JSON file back into your recording list.
|
and gentle vertical oscillation, wiggle, ramp-up over 1–60 s.
|
||||||
- Clear/Delete: remove one recording or clear the entire list.
|
- **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
|
### Presets
|
||||||
- Save a Preset: name the current setup (visuals and audio) and save.
|
- Save/apply/delete full setups (visuals + audio); import/export all presets
|
||||||
- Apply: pick a preset to immediately apply all saved settings.
|
as one JSON file.
|
||||||
- Delete: remove a preset you no longer need.
|
|
||||||
- Import/Export: backup or share all presets as a single JSON file.
|
|
||||||
|
|
||||||
### Controllers and Shortcuts
|
### Controllers
|
||||||
- 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.
|
- Map keyboard keys, MIDI notes/CCs, or a WebHID dial to actions: start/stop,
|
||||||
- MIDI Controllers: connect a MIDI device and map buttons/knobs to the same actions; the mapping panel shows what’s currently assigned.
|
speed, volume, toggles, next shape/direction/sound, lock.
|
||||||
- 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.
|
- Press `L` to lock the interface against stray clicks; hold `U` to unlock.
|
||||||
- Lock/Unlock: press `L` to lock the interface (prevents accidental clicks). Hold `U` to unlock; the banner shows the lock status.
|
|
||||||
|
|
||||||
### Tips and Safety
|
## Network mode details
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Nerd Stuff
|
- The relay (`server.py`, Python 3 stdlib only) serves the therapist page on
|
||||||
|
`http://<your-ip>:8000/` and the WebSocket relay on `ws://<your-ip>: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:
|
Everything stays in the browser on the therapist machine:
|
||||||
- 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
|
|
||||||
|
|
||||||
## 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
|
Nothing is sent anywhere except live stimulus state to the patient view.
|
||||||
- First‑Time Setup
|
|
||||||
- Double‑click `setup.bat`
|
|
||||||
- Allow the Windows Defender Firewall prompt (Private networks)
|
|
||||||
- Your browser opens at `http://<your‑IP>: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
|
|
||||||
|
|
||||||
### macOS
|
## License
|
||||||
- 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://<your‑IP>: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
|
|
||||||
|
|
||||||
### Linux
|
MIT — see [LICENSE](LICENSE). You can freely copy, hack, and share this with
|
||||||
- First‑Time Setup
|
blessings.
|
||||||
- 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://<your‑IP>: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://<your‑IP>:8787` and copies the Patient link
|
|
||||||
- Admin page loads at `http://<your‑IP>: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://<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
|
|
||||||
|
|
||||||
### 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://<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.
|
|
||||||
|
|
||||||
## 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://<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`
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## ⚠️ Disclaimer
|
## ⚠️ 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.
|
||||||
|
|||||||
38
index.html
38
index.html
@ -8,6 +8,29 @@
|
|||||||
<link rel="stylesheet" href="css/admin.css">
|
<link rel="stylesheet" href="css/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="safetyNotice" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,0.8); overflow:auto;">
|
||||||
|
<div style="max-width:560px; margin:8vh auto; background:#1c2128; color:#e6edf3; border-radius:12px; padding:28px; font-family:system-ui,-apple-system,sans-serif; line-height:1.55;">
|
||||||
|
<h2 style="margin-top:0">⚠️ Read before first use</h2>
|
||||||
|
<ul style="padding-left:20px">
|
||||||
|
<li style="margin-bottom:10px"><strong>Photosensitivity:</strong> moving and pulsing visuals can trigger seizures in people with photosensitive epilepsy. Ask about seizure history before use, keep Glow Pulse rate low, and stop immediately if the viewer feels unwell.</li>
|
||||||
|
<li style="margin-bottom:10px"><strong>Clinical use:</strong> bilateral stimulation can surface intense memories and emotions. This tool assists a trained clinician — it is not therapy by itself and not for unsupervised trauma processing.</li>
|
||||||
|
<li style="margin-bottom:10px"><strong>Privacy:</strong> patient records and notes are stored unencrypted in this browser. Use initials or a code instead of full names, and treat this computer as part of the clinical record.</li>
|
||||||
|
</ul>
|
||||||
|
<button id="safetyAck" style="width:100%; padding:12px; border:none; border-radius:8px; background:#1f6feb; color:#fff; font-size:15px; cursor:pointer">I understand</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var seen = false; try{ seen = localStorage.getItem('bls_safety_ack')==='1' }catch(_){ }
|
||||||
|
if(seen) return;
|
||||||
|
var n = document.getElementById('safetyNotice');
|
||||||
|
n.style.display = 'block';
|
||||||
|
document.getElementById('safetyAck').addEventListener('click', function(){
|
||||||
|
try{ localStorage.setItem('bls_safety_ack','1') }catch(_){ }
|
||||||
|
n.style.display = 'none';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<header>
|
<header>
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1>Bilateral Stimulation Simulator</h1>
|
<h1>Bilateral Stimulation Simulator</h1>
|
||||||
@ -154,7 +177,8 @@
|
|||||||
|
|
||||||
<div class="control-group control-indent">
|
<div class="control-group control-indent">
|
||||||
<label class="control-label">Rate: <span id="glowRateLabel">0.5 Hz</span></label>
|
<label class="control-label">Rate: <span id="glowRateLabel">0.5 Hz</span></label>
|
||||||
<input type="range" id="glowRate" min="0.1" max="3" step="0.1" value="0.5" class="slider">
|
<!-- max 2.5 Hz: photosensitive-epilepsy trigger band starts around 3 Hz -->
|
||||||
|
<input type="range" id="glowRate" min="0.1" max="2.5" step="0.1" value="0.5" class="slider">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
@ -506,9 +530,12 @@
|
|||||||
|
|
||||||
<div class="patient-controls">
|
<div class="patient-controls">
|
||||||
<div class="control-row">
|
<div class="control-row">
|
||||||
<input type="text" id="patientName" placeholder="Patient name..." class="text-input flex-1">
|
<input type="text" id="patientName" placeholder="Initials or code (not full name)..." class="text-input flex-1">
|
||||||
<button id="savePatient" class="btn-secondary">Save</button>
|
<button id="savePatient" class="btn-secondary">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-label" style="opacity:0.75; font-size:12px">
|
||||||
|
🔒 Stored unencrypted in this browser — use initials or a code, never a full name.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="control-row">
|
<div class="control-row">
|
||||||
<select id="patientList" class="select-input flex-1"></select>
|
<select id="patientList" class="select-input flex-1"></select>
|
||||||
@ -543,6 +570,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<textarea id="sessionNotes" class="textarea-input" placeholder="Enter session notes..."></textarea>
|
<textarea id="sessionNotes" class="textarea-input" placeholder="Enter session notes..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row">
|
||||||
|
<button id="exportAllData" class="btn-secondary btn-small">Export All Patient Data</button>
|
||||||
|
<button id="wipeAllData" class="btn-danger-outline btn-small">Wipe All Patient Data</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -594,7 +626,7 @@
|
|||||||
<label class="control-label">Connection Details</label>
|
<label class="control-label">Connection Details</label>
|
||||||
<div class="connection-inputs">
|
<div class="connection-inputs">
|
||||||
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" class="text-input">
|
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" class="text-input">
|
||||||
<input type="text" id="wsSession" placeholder="room-1" class="text-input">
|
<input type="text" id="wsSession" placeholder="auto (random room id)" class="text-input">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
20
js/admin.js
20
js/admin.js
@ -97,6 +97,8 @@ var endBtn = document.getElementById('endSession');
|
|||||||
var sessionNotesEl = document.getElementById('sessionNotes');
|
var sessionNotesEl = document.getElementById('sessionNotes');
|
||||||
var saveSessionNotesBtn = document.getElementById('saveSessionNotes');
|
var saveSessionNotesBtn = document.getElementById('saveSessionNotes');
|
||||||
var deleteSessionSingleBtn = document.getElementById('deleteSession');
|
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<x.length;i++){ x[i]=Math.floor(Math.random()*0xffffffff) } }}).getRandomValues(a); return 'room-' + (a[0].toString(36)+a[1].toString(36)).slice(0,8) }
|
||||||
function setActive(group, dataKey, stateKey){
|
function setActive(group, dataKey, stateKey){
|
||||||
var btns = group.querySelectorAll('button');
|
var btns = group.querySelectorAll('button');
|
||||||
for(var i=0;i<btns.length;i++){
|
for(var i=0;i<btns.length;i++){
|
||||||
@ -119,7 +121,7 @@ function updateTimerLabel(){ var m = Math.max(0, parseInt(timerMinutes.value||'0
|
|||||||
function updateVolumeLabel(){ volumeLabel.textContent = volume.value+'%' }
|
function updateVolumeLabel(){ volumeLabel.textContent = volume.value+'%' }
|
||||||
function setProgramMode(){ if(programControls) programControls.style.display = 'flex'; applyState({audioMode: 'programmatic'}, true) }
|
function setProgramMode(){ if(programControls) programControls.style.display = 'flex'; applyState({audioMode: 'programmatic'}, true) }
|
||||||
function updateKnobSensLabel(){ knobSensLabel.textContent = 'x'+knobSens.value }
|
function updateKnobSensLabel(){ knobSensLabel.textContent = 'x'+knobSens.value }
|
||||||
function refreshPatientList(){ var m = loadPatients(); var names = Object.keys(m).sort(); var html = names.map(function(n){ return '<option value="'+n+'">'+n+'</option>' }).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 '<option value="'+esc(n)+'">'+esc(n)+'</option>' }).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 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){} }
|
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 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 = '<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('') } } 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 '<option value="'+o.i+'">'+o.label+'</option>' }).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 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('') } } 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 '<option value="'+o.i+'">'+o.label+'</option>' }).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 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<names.length;i++){ var n=names[i]; html += '<option value="'+n+'">'+n+'</option>' } presetList.innerHTML = html }
|
function refreshPresetList(){ var names = BLS.listPresets(); var html=''; for(var i=0;i<names.length;i++){ var n=names[i]; html += '<option value="'+esc(n)+'">'+esc(n)+'</option>' } presetList.innerHTML = html }
|
||||||
function applyState(next, broadcast){
|
function applyState(next, broadcast){
|
||||||
var prevRunning = state && !!state.running;
|
var prevRunning = state && !!state.running;
|
||||||
// Explicitly persist=true for Admin actions
|
// 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() }) }
|
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');
|
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() }) }
|
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');
|
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<btns.length;i++){ btns[i].classList.toggle('active', btns[i].dataset.mult==String(mult)) } var base = parseInt(speed.value,10)||250; updateSpeedLabel(); applyState({ speed: base*mult, speedMultiplier: mult }, true) }) }
|
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<btns.length;i++){ btns[i].classList.toggle('active', btns[i].dataset.mult==String(mult)) } updateSpeedLabel(); applyState({ speedMultiplier: mult }, true) }) }
|
||||||
savePatientBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); setPatient(p); refreshPatientList(); patientName.value=''; refreshPatientUI() })
|
savePatientBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); setPatient(p); refreshPatientList(); patientName.value=''; refreshPatientUI() })
|
||||||
if(saveSessionNotesBtn){ saveSessionNotesBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); var idx = parseInt((sessionSelect && sessionSelect.value)||'-1',10); if(!p.sessions || !(p.sessions[idx])) return; p.sessions[idx].notes = (sessionNotesEl && sessionNotesEl.value)||''; setPatient(p); refreshPatientUI() }) }
|
if(saveSessionNotesBtn){ saveSessionNotesBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); var idx = parseInt((sessionSelect && sessionSelect.value)||'-1',10); if(!p.sessions || !(p.sessions[idx])) return; p.sessions[idx].notes = (sessionNotesEl && sessionNotesEl.value)||''; setPatient(p); refreshPatientUI() }) }
|
||||||
if(loadSessionBtn){ loadSessionBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); var idx = parseInt((sessionSelect && sessionSelect.value)||'-1',10); if(!p.sessions || !(p.sessions[idx])) return; if(sessionNotesEl){ sessionNotesEl.value = p.sessions[idx].notes||'' } }) }
|
if(loadSessionBtn){ loadSessionBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); var idx = parseInt((sessionSelect && sessionSelect.value)||'-1',10); if(!p.sessions || !(p.sessions[idx])) return; if(sessionNotesEl){ sessionNotesEl.value = p.sessions[idx].notes||'' } }) }
|
||||||
@ -310,7 +316,7 @@ function stopTimer(){
|
|||||||
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) });
|
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');
|
var detectIPBtn = document.getElementById('detectIP');
|
||||||
if(detectIPBtn){ detectIPBtn.addEventListener('click', function(){
|
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) } } }
|
function setUrl(ip){ if(!ip) return; wsUrl.value = 'ws://' + ip + ':8787'; if(!(wsSession.value||'').trim()){ wsSession.value = randSid() } 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 }
|
var host = location.hostname; if(host && host !== 'localhost' && host !== '127.0.0.1'){ setUrl(host); return }
|
||||||
// Attempt server-assisted detection
|
// Attempt server-assisted detection
|
||||||
(function(){
|
(function(){
|
||||||
@ -434,8 +440,8 @@ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded
|
|||||||
}catch(e){}
|
}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 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 renderOsHelp(){ var ua = navigator.userAgent||''; var isWin = /Windows/i.test(ua); var isMac = /Macintosh|Mac OS/i.test(ua); var start = (isWin? 'start-relay.bat' : isMac? 'start-relay.command' : './start-relay.sh'); var open = (isWin? 'double-click' : isMac? 'double-click' : 'run'); return { setup: start, 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(){ var el = document.getElementById('osHelp'); if(!el) return; var h = renderOsHelp(); var text = 'Start the server: ' + h.open + ' ' + h.start + ' — nothing to install, first time or any time'; 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 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 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 btn = document.getElementById('wsConnect'); if(btn){ btn.disabled = true } })();
|
||||||
@ -453,7 +459,7 @@ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded
|
|||||||
})();
|
})();
|
||||||
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(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){} }) }
|
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' } else if(msg && msg.type==='patientViewport'){ var pv = msg.payload||{}; try{ window.PatientViewport = pv }catch(_){ } var r = window.AdminRenderer; if(r){ r.resize(parseInt(pv.canvasW||640,10), parseInt(pv.canvasH||360,10)) } var pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } } }catch(e){} } }
|
function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim(); if(!sid){ sid = randSid(); wsSession.value = sid; updateJoinLink() } 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' } else if(msg && msg.type==='patientViewport'){ var pv = msg.payload||{}; try{ window.PatientViewport = pv }catch(_){ } var r = window.AdminRenderer; if(r){ r.resize(parseInt(pv.canvasW||640,10), parseInt(pv.canvasH||360,10)) } var pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } } }catch(e){} } }
|
||||||
function wsDisconnect(){ if(ws){ try{ ws.close() }catch(e){} } ws=null; wsConnected=false; wsStatus.textContent = 'Disconnected' }
|
function wsDisconnect(){ if(ws){ try{ ws.close() }catch(e){} } ws=null; wsConnected=false; wsStatus.textContent = 'Disconnected' }
|
||||||
function refreshNetMode(){ var m = (netModeSel && netModeSel.value) || 'relay'; if(rtcControls) rtcControls.style.display = (m==='webrtc'? 'flex':'none'); var relayRows = document.querySelectorAll('#osHelp, #wsUrl, #wsSession, #detectIP, #wsConnect, #wsDisconnect'); for(var i=0;i<relayRows.length;i++){ var el=relayRows[i]; if(!el) continue }
|
function refreshNetMode(){ var m = (netModeSel && netModeSel.value) || 'relay'; if(rtcControls) rtcControls.style.display = (m==='webrtc'? 'flex':'none'); var relayRows = document.querySelectorAll('#osHelp, #wsUrl, #wsSession, #detectIP, #wsConnect, #wsDisconnect'); for(var i=0;i<relayRows.length;i++){ var el=relayRows[i]; if(!el) continue }
|
||||||
var btn = document.getElementById('wsConnect'); if(btn) btn.disabled = (m==='webrtc'); var s = document.getElementById('wsStatus'); if(s && m==='webrtc'){ s.textContent = 'P2P mode' }
|
var btn = document.getElementById('wsConnect'); if(btn) btn.disabled = (m==='webrtc'); var s = document.getElementById('wsStatus'); if(s && m==='webrtc'){ s.textContent = 'P2P mode' }
|
||||||
|
|||||||
@ -395,7 +395,7 @@
|
|||||||
return node
|
return node
|
||||||
},
|
},
|
||||||
|
|
||||||
startPinkNoisePan: function(speedHz){
|
startPinkNoisePan: function(speedHz, enablePan){
|
||||||
this.stopNoise();
|
this.stopNoise();
|
||||||
var ctx=this.ensureCtx();
|
var ctx=this.ensureCtx();
|
||||||
var noise=this.createPinkNoise();
|
var noise=this.createPinkNoise();
|
||||||
@ -405,6 +405,10 @@
|
|||||||
p.connect(this.master);
|
p.connect(this.master);
|
||||||
if(noise.start) noise.start();
|
if(noise.start) noise.start();
|
||||||
this.noiseNodes.push(noise);
|
this.noiseNodes.push(noise);
|
||||||
|
if(enablePan === false){ // Pan Sync off: steady centered noise
|
||||||
|
if(p.setPan) p.setPan(0); else if(p.pan) p.pan.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
var rate=speedHz||0.5;
|
var rate=speedHz||0.5;
|
||||||
var start=ctx.currentTime;
|
var start=ctx.currentTime;
|
||||||
var self=this;
|
var self=this;
|
||||||
|
|||||||
@ -193,7 +193,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function effectiveSpeed(w, h, r){
|
function effectiveSpeed(w, h, r){
|
||||||
var base = state.speed;
|
// speed stays the slider's base value; the boost multiplier applies here,
|
||||||
|
// so presets and reloads never compound a pre-multiplied speed
|
||||||
|
var base = state.speed * (state.speedMultiplier || 1);
|
||||||
var ramp = 1;
|
var ramp = 1;
|
||||||
|
|
||||||
if(state.rampEnabled && state.startedAt && state.rampSeconds > 0){
|
if(state.rampEnabled && state.startedAt && state.rampSeconds > 0){
|
||||||
|
|||||||
@ -258,10 +258,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var progRunning = false;
|
var progRunning = false;
|
||||||
|
var noiseSig = null; // signature of the running noise source, so slider drags don't restart it
|
||||||
|
|
||||||
function stopProgrammatic(){
|
function stopProgrammatic(){
|
||||||
AudioEngine.stopNoise();
|
AudioEngine.stopNoise();
|
||||||
progRunning = false;
|
progRunning = false;
|
||||||
|
noiseSig = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startProgrammatic(st){
|
function startProgrammatic(st){
|
||||||
@ -276,19 +278,20 @@
|
|||||||
// Clear previous update handlers related to audio
|
// Clear previous update handlers related to audio
|
||||||
renderer.setOnUpdate(onRenderUpdate);
|
renderer.setOnUpdate(onRenderUpdate);
|
||||||
|
|
||||||
|
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'){
|
if(st.programPreset === 'pink'){
|
||||||
if(st.syncPan){
|
AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate || 0.5), st.syncPan !== false);
|
||||||
// We handle panning in onRenderUpdate
|
|
||||||
AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate || 0.5));
|
|
||||||
} else {
|
} else {
|
||||||
AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate || 0.5));
|
AudioEngine.startHybrid(Math.max(0.1, st.cueRate || 1.2));
|
||||||
}
|
}
|
||||||
progRunning = true;
|
progRunning = true;
|
||||||
} else if(st.programPreset === 'hybrid'){
|
|
||||||
AudioEngine.startHybrid(Math.max(0.1, st.cueRate || 1.2));
|
|
||||||
progRunning = true;
|
|
||||||
} else {
|
} 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){
|
renderer.setOnUpdate(function(info){
|
||||||
onRenderUpdate(info); // Call the base update for visual/panner sync
|
onRenderUpdate(info); // Call the base update for visual/panner sync
|
||||||
|
|
||||||
|
|||||||
302
server.py
302
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://<ip>:8000/ and the session relay on
|
||||||
|
ws://<ip>: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 json
|
||||||
import websockets
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
SESSIONS = {}
|
HTTP_PORT = 8000
|
||||||
STOP = None
|
WS_PORT = 8787
|
||||||
|
_WS_GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||||
|
|
||||||
async def handler(ws):
|
SESSIONS = {} # session id -> set of live WSHandler
|
||||||
session_id = None
|
LOCK = threading.Lock()
|
||||||
try:
|
_SERVERS = []
|
||||||
async for msg in ws:
|
|
||||||
try:
|
|
||||||
data = json.loads(msg)
|
def local_ip():
|
||||||
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:
|
try:
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
s.connect(('8.8.8.8', 80))
|
s.connect(('8.8.8.8', 80))
|
||||||
ip = s.getsockname()[0]
|
ip = s.getsockname()[0]
|
||||||
s.close()
|
s.close()
|
||||||
except Exception:
|
return ip
|
||||||
ip = socket.gethostbyname(socket.gethostname())
|
except OSError:
|
||||||
try:
|
try:
|
||||||
await ws.send(json.dumps({'type':'detect_ip','ip': ip}))
|
return socket.gethostbyname(socket.gethostname())
|
||||||
except Exception:
|
except OSError:
|
||||||
pass
|
return '127.0.0.1'
|
||||||
elif t == 'shutdown':
|
|
||||||
try:
|
|
||||||
ra = ws.remote_address[0] if ws.remote_address else None
|
def shutdown_all():
|
||||||
except Exception:
|
for srv in _SERVERS:
|
||||||
ra = None
|
try:
|
||||||
local_ip = '127.0.0.1'
|
srv.shutdown()
|
||||||
try:
|
except Exception:
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
pass
|
||||||
s.connect(('8.8.8.8', 80))
|
|
||||||
local_ip = s.getsockname()[0]
|
|
||||||
s.close()
|
class WSHandler(socketserver.BaseRequestHandler):
|
||||||
except Exception:
|
"""Minimal RFC6455 server: unfragmented text frames, ping/pong, close.
|
||||||
try:
|
|
||||||
local_ip = socket.gethostbyname(socket.gethostname())
|
ponytail: no fragmentation/binary support — the state payloads are a few
|
||||||
except Exception:
|
hundred bytes of JSON and browsers never fragment at that size.
|
||||||
local_ip = '127.0.0.1'
|
"""
|
||||||
if ra in ('127.0.0.1', '::1', local_ip):
|
|
||||||
try:
|
def setup(self):
|
||||||
await ws.send(json.dumps({'type':'shutdown_ack'}))
|
self.session_id = None
|
||||||
except Exception:
|
self.send_lock = threading.Lock()
|
||||||
pass
|
|
||||||
if STOP and not STOP.done():
|
def handle(self):
|
||||||
STOP.set_result(True)
|
if not self._handshake():
|
||||||
elif t == 'ping':
|
return
|
||||||
await ws.send(json.dumps({'type':'pong'}))
|
try:
|
||||||
finally:
|
while True:
|
||||||
if session_id and ws in SESSIONS.get(session_id, set()):
|
msg = self._read_message()
|
||||||
SESSIONS[session_id].discard(ws)
|
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:
|
||||||
|
peer._send_text(out)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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__':
|
if __name__ == '__main__':
|
||||||
asyncio.run(main())
|
main()
|
||||||
|
|||||||
25
setup.bat
25
setup.bat
@ -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
|
|
||||||
28
setup.sh
28
setup.sh
@ -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
|
|
||||||
@ -1,23 +1,4 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal enabledelayedexpansion
|
rem backnforth: static site + relay in one process, stdlib only - nothing to install
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
set USEPY=
|
py server.py %* 2>nul || python server.py %*
|
||||||
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
|
|
||||||
|
|||||||
6
start-relay.command
Normal file → Executable file
6
start-relay.command
Normal file → Executable file
@ -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")"
|
cd "$(dirname "$0")"
|
||||||
chmod +x ./start-relay.sh
|
exec python3 server.py "$@"
|
||||||
./start-relay.sh
|
|
||||||
|
|||||||
@ -1,22 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/bin/sh
|
||||||
set -euo pipefail
|
# backnforth: static site + relay in one process, stdlib only - nothing to install
|
||||||
cd "$(dirname "$0")"
|
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
|
exec python3 server.py "$@"
|
||||||
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
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user