enjoy
This commit is contained in:
commit
3c9646cf0b
102
README.md
Normal file
102
README.md
Normal file
@ -0,0 +1,102 @@
|
||||
# Bilateral Stimulation Simulator
|
||||
|
||||
Simple browser-based tool with an Admin panel and a full-screen Patient view. The Admin controls everything; the Patient view mirrors motion and audio.
|
||||
|
||||
## 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
|
||||
|
||||
## Data Storage
|
||||
|
||||
- Presets: `localStorage` key `bls_presets`
|
||||
- Patients: `localStorage` key `bls_patients`
|
||||
|
||||
## 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
|
||||
|
||||
This tool is for research, education, and personal experimentation with bilateral stimulation. It is NOT a substitute for professional therapy, diagnosis, or treatment. Use at your own discretion.
|
||||
265
index.html
Normal file
265
index.html
Normal file
@ -0,0 +1,265 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Bilataral Stimulation Simulator Admin</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #f7f7f7; color: #111; }
|
||||
header { padding: 16px 20px; background: #222; color: #fff; display: flex; align-items: center; justify-content: space-between; }
|
||||
header h1 { font-size: 18px; margin: 0; }
|
||||
main { display: grid; grid-template-columns: 380px 1fr; gap: 16px; padding: 16px; }
|
||||
.panel { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
|
||||
.panel h2 { font-size: 14px; margin: 0 0 8px; color: #333; }
|
||||
.row { display: flex; gap: 10px; align-items: center; margin: 8px 0; flex-wrap: wrap; }
|
||||
.row label { font-size: 13px; color: #333; }
|
||||
.controls button { padding: 8px 10px; border: 1px solid #ccc; border-radius: 6px; background: #fafafa; cursor: pointer; }
|
||||
.controls button.active { background: #1f6feb; color: #fff; border-color: #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,0.4) inset; }
|
||||
#easingControls button.active { background: #1f6feb; color: #fff; border-color: #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,0.4) inset; }
|
||||
.controls input[type="range"] { width: 180px; }
|
||||
.preview { display: flex; align-items: center; justify-content: center; height: 220px; background: #000; border-radius: 8px; overflow: hidden; }
|
||||
.preview { width: 360px; margin: 0 auto; }
|
||||
.previewContainer { justify-content: flex-start; }
|
||||
.previewCanvas { display: block; margin: 0; }
|
||||
.footer { display: flex; gap: 8px; }
|
||||
.footer button { padding: 10px 12px; border: none; border-radius: 6px; cursor: pointer; }
|
||||
.footer .primary { background: #1f6feb; color: #fff; }
|
||||
.footer .danger { background: #b31d1d; color: #fff; }
|
||||
.primary { background: #2da44e; color: #fff; border: none; padding: 12px 18px; border-radius: 8px; font-weight: 600; }
|
||||
.danger { background: #d1242f; color: #fff; border: none; padding: 12px 18px; border-radius: 8px; font-weight: 600; }
|
||||
#patientControls .danger { background: transparent; color: #d1242f; border: 1px solid #d1242f; padding: 6px 10px; border-radius: 6px; font-weight: 500; }
|
||||
.metrics { display: flex; gap: 12px; font-size: 13px; color: #444; }
|
||||
.small { font-size: 12px; color: #666; display: inline-block; min-width: 60px; }
|
||||
.field { display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
|
||||
.field label { min-width: 90px; }
|
||||
#speed { width: 360px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>BILATERAL STIMULATION SIMULATOR</h1>
|
||||
<div>
|
||||
<button id="openPatient">Open Patient View</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="panel">
|
||||
<h2>Session Controls</h2>
|
||||
<div class="row controls" id="shapeControls">
|
||||
<label>Shape:</label>
|
||||
<button data-shape="circle" class="active">Circle</button>
|
||||
<button data-shape="square">Square</button>
|
||||
<button data-shape="triangle">Triangle</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Shape Color:</label>
|
||||
<input type="color" id="shapeColor" value="#00ff88">
|
||||
<div id="shapePreset" class="controls">
|
||||
<button data-color="#00ff00" style="background:#00ff00;color:#000">Green</button>
|
||||
<button data-color="#0066ff" style="background:#0066ff;color:#fff">Blue</button>
|
||||
<button data-color="#ffd400" style="background:#ffd400;color:#000">Yellow</button>
|
||||
<button data-color="#ff2a2a" style="background:#ff2a2a;color:#fff">Red</button>
|
||||
<button data-color="#ffffff" style="background:#ffffff;color:#000">White</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Background:</label>
|
||||
<input type="color" id="bgColor" value="#000000">
|
||||
<div id="bgPreset" class="controls">
|
||||
<button data-color="#00ff00" style="background:#00ff00;color:#000">Green</button>
|
||||
<button data-color="#0066ff" style="background:#0066ff;color:#fff">Blue</button>
|
||||
<button data-color="#ffd400" style="background:#ffd400;color:#000">Yellow</button>
|
||||
<button data-color="#ff2a2a" style="background:#ff2a2a;color:#fff">Red</button>
|
||||
<button data-color="#ffffff" style="background:#ffffff;color:#000">White</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row controls" id="directionControls">
|
||||
<label>Direction:</label>
|
||||
<button data-direction="lr" class="active">Left ↔ Right</button>
|
||||
<button data-direction="ud">Up ↕ Down</button>
|
||||
<button data-direction="diag_down">Diagonal ↘︎</button>
|
||||
<button data-direction="diag_up">Diagonal ↗︎</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Size:</label>
|
||||
<input type="range" id="size" min="10" max="100" step="2" value="28">
|
||||
<span id="sizeLabel" class="small">28 px</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Vertical Position:</label>
|
||||
<input type="range" id="yPercent" min="0" max="100" step="1" value="50">
|
||||
<span id="yPercentLabel" class="small">50%</span>
|
||||
<button id="yReset">Reset</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Glow Pulse:</label>
|
||||
<label class="small"><input type="checkbox" id="glowEnable"> Enable</label>
|
||||
<div class="field">
|
||||
<label>Glow Radius:</label>
|
||||
<input type="range" id="glowIntensity" min="0" max="200" step="5" value="100">
|
||||
<span id="glowIntensityLabel" class="small">100%</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Glow Rate:</label>
|
||||
<input type="range" id="glowRate" min="0.1" max="3" step="0.1" value="0.5">
|
||||
<span id="glowRateLabel" class="small">0.5 Hz</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Ramp Speed:</label>
|
||||
<label class="small"><input type="checkbox" id="rampEnable"> Enable</label>
|
||||
<input type="range" id="rampSeconds" min="1" max="60" step="1" value="10">
|
||||
<span id="rampLabel" class="small">10 s</span>
|
||||
</div>
|
||||
<div class="row controls" id="easingControls">
|
||||
<label>Smoothness:</label>
|
||||
<button data-easing="linear" class="active">Linear</button>
|
||||
<button data-easing="ease">Ease In-Out</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Edge Pause:</label>
|
||||
<input type="range" id="edgePause" min="0" max="150" step="10" value="0">
|
||||
<span id="edgePauseLabel" class="small">0 ms</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Wiggle:</label>
|
||||
<label class="small"><input type="checkbox" id="wiggleEnable"> Enable</label>
|
||||
<input type="range" id="wiggleAmplitude" min="0" max="10" step="1" value="0">
|
||||
<span id="wiggleLabel" class="small">0 px</span>
|
||||
</div>
|
||||
|
||||
<div class="row" id="presetControls">
|
||||
<label>Presets:</label>
|
||||
<div class="field">
|
||||
<label>Name:</label>
|
||||
<input type="text" id="presetName" placeholder="My Preset" style="width:160px">
|
||||
<button id="savePreset">Save</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Apply:</label>
|
||||
<select id="presetList"></select>
|
||||
<button id="applyPreset">Apply</button>
|
||||
<button id="deletePreset">Delete</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Import/Export:</label>
|
||||
<button id="exportPresets">Export JSON</button>
|
||||
<button id="importPresets">Import JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="audioControls">
|
||||
<label>Audio:</label>
|
||||
<select id="audioMode">
|
||||
<option value="programmatic">Programmatic</option>
|
||||
<option value="file">From Files</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
<label class="small"><input type="checkbox" id="audioEnable"> Enable</label>
|
||||
<button id="testSound">Test Sound</button>
|
||||
<div id="programmaticControls" class="row">
|
||||
<div class="field">
|
||||
<label>Preset:</label>
|
||||
<select id="programPreset">
|
||||
<option value="pink">Pink Noise</option>
|
||||
<option value="hybrid">Hybrid (noise + edge cues)</option>
|
||||
<option value="click">Click</option>
|
||||
<option value="ping">Ping</option>
|
||||
<option value="woodblock">Woodblock</option>
|
||||
<option value="bell">Bell</option>
|
||||
<option value="bass">Bass</option>
|
||||
<option value="kick">Kick</option>
|
||||
<option value="snare">Snare</option>
|
||||
<option value="hihat">Hi-Hat</option>
|
||||
<option value="sweep">Sweep</option>
|
||||
<option value="zap">Zap</option>
|
||||
<option value="bubble">Bubble</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="small"><input type="checkbox" id="syncPan" checked> Pan sync to movement</label>
|
||||
<div class="field">
|
||||
<label>Pan Rate:</label>
|
||||
<input type="range" id="panRate" min="0.1" max="2" step="0.1" value="0.5">
|
||||
<span id="panRateLabel" class="small">0.5 Hz</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Cue Rate:</label>
|
||||
<input type="range" id="cueRate" min="0.1" max="3" step="0.1" value="1.2">
|
||||
<span id="cueRateLabel" class="small">1.2 Hz</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Volume:</label>
|
||||
<input type="range" id="volume" min="0" max="100" step="5" value="60">
|
||||
<span id="volumeLabel" class="small">60%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fileControls" class="row">
|
||||
<button id="pickAudioFolder">Choose Folder</button>
|
||||
<select id="audioSelect"><option value="">No audio</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="deviceControls">
|
||||
<label>Controller:</label>
|
||||
<button id="connectHID">Connect HID Device</button>
|
||||
<span id="deviceStatus" class="small">Not connected</span>
|
||||
<label>Sensitivity:</label>
|
||||
<input type="range" id="knobSens" min="1" max="50" step="1" value="5">
|
||||
<span id="knobSensLabel" class="small">x5</span>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Preview</h2>
|
||||
<div class="preview previewContainer" id="previewContainer">
|
||||
<canvas class="previewCanvas" id="previewCanvas" width="640" height="360"></canvas>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Speed:</label>
|
||||
<input type="range" id="speed" min="50" max="600" step="10" value="250">
|
||||
<span id="speedLabel" class="small">250 px/s</span>
|
||||
</div>
|
||||
<div class="row controls" id="speedBoost">
|
||||
<label>Speed Boost:</label>
|
||||
<button data-mult="1" class="active">1×</button>
|
||||
<button data-mult="2">2×</button>
|
||||
<button data-mult="3">3×</button>
|
||||
<button data-mult="4">4×</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<label>Timer (minutes):</label>
|
||||
<input type="number" id="timerMinutes" min="0" value="10" style="width:80px">
|
||||
<span id="timerDisplay" class="small">10:00</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary" id="startSession">Start Session</button>
|
||||
<button class="danger" id="endSession">End Session</button>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div>Status: <span id="statusText">Idle</span></div>
|
||||
<div>Elapsed: <span id="elapsedText">00:00</span></div>
|
||||
</div>
|
||||
<div class="row" id="patientControls">
|
||||
<label>Patient:</label>
|
||||
<input type="text" id="patientName" placeholder="Patient Name" style="width:180px">
|
||||
<button id="savePatient">Save Patient</button>
|
||||
<select id="patientList" style="min-width:160px"></select>
|
||||
<button id="loadPatient">Load Patient</button>
|
||||
<button id="clearSessions">Clear Sessions</button>
|
||||
<button id="deletePatient" class="danger">Delete Patient</button>
|
||||
<span class="small">Sessions: <span id="sessionCount">0</span></span>
|
||||
<span class="small">Last: <span id="lastSessionInfo">—</span></span>
|
||||
</div>
|
||||
<div class="row" id="patientHistory">
|
||||
<label>Session History:</label>
|
||||
<div id="sessionHistory" class="small" style="max-height:160px; overflow:auto; flex:1"></div>
|
||||
</div>
|
||||
<div class="small">This preview mirrors the patient's view.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="js/bls.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
186
js/admin.js
Normal file
186
js/admin.js
Normal file
@ -0,0 +1,186 @@
|
||||
function initAdmin(){
|
||||
var previewCanvas = document.getElementById('previewCanvas');
|
||||
var renderer = BLS.Renderer(previewCanvas);
|
||||
var chan = BLS.createChannel('bls-session');
|
||||
var state = BLS.loadState();
|
||||
renderer.setState(state);
|
||||
var shapeControls = document.getElementById('shapeControls');
|
||||
var directionControls = document.getElementById('directionControls');
|
||||
var easingControls = document.getElementById('easingControls');
|
||||
var shapeColor = document.getElementById('shapeColor');
|
||||
var bgColor = document.getElementById('bgColor');
|
||||
var shapePreset = document.getElementById('shapePreset');
|
||||
var bgPreset = document.getElementById('bgPreset');
|
||||
var speed = document.getElementById('speed');
|
||||
var speedLabel = document.getElementById('speedLabel');
|
||||
var size = document.getElementById('size');
|
||||
var sizeLabel = document.getElementById('sizeLabel');
|
||||
var yPercent = document.getElementById('yPercent');
|
||||
var yPercentLabel = document.getElementById('yPercentLabel');
|
||||
var yReset = document.getElementById('yReset');
|
||||
var glowEnable = document.getElementById('glowEnable');
|
||||
var glowIntensity = document.getElementById('glowIntensity');
|
||||
var glowIntensityLabel = document.getElementById('glowIntensityLabel');
|
||||
var glowRate = document.getElementById('glowRate');
|
||||
var glowRateLabel = document.getElementById('glowRateLabel');
|
||||
var rampEnable = document.getElementById('rampEnable');
|
||||
var rampSeconds = document.getElementById('rampSeconds');
|
||||
var rampLabel = document.getElementById('rampLabel');
|
||||
var edgePause = document.getElementById('edgePause');
|
||||
var edgePauseLabel = document.getElementById('edgePauseLabel');
|
||||
var wiggleEnable = document.getElementById('wiggleEnable');
|
||||
var wiggleAmplitude = document.getElementById('wiggleAmplitude');
|
||||
var wiggleLabel = document.getElementById('wiggleLabel');
|
||||
var presetName = document.getElementById('presetName');
|
||||
var presetList = document.getElementById('presetList');
|
||||
var savePresetBtn = document.getElementById('savePreset');
|
||||
var applyPresetBtn = document.getElementById('applyPreset');
|
||||
var deletePresetBtn = document.getElementById('deletePreset');
|
||||
var exportPresetsBtn = document.getElementById('exportPresets');
|
||||
var importPresetsBtn = document.getElementById('importPresets');
|
||||
var timerMinutes = document.getElementById('timerMinutes');
|
||||
var timerDisplay = document.getElementById('timerDisplay');
|
||||
var statusText = document.getElementById('statusText');
|
||||
var elapsedText = document.getElementById('elapsedText');
|
||||
var startBtn = document.getElementById('startSession');
|
||||
var endBtn = document.getElementById('endSession');
|
||||
var openPatient = document.getElementById('openPatient');
|
||||
var audioSelect = document.getElementById('audioSelect');
|
||||
var pickAudioFolder = document.getElementById('pickAudioFolder');
|
||||
var audioEnable = document.getElementById('audioEnable');
|
||||
var audioMode = document.getElementById('audioMode');
|
||||
var programControls = document.getElementById('programmaticControls');
|
||||
var fileControls = document.getElementById('fileControls');
|
||||
var programPreset = document.getElementById('programPreset');
|
||||
var syncPan = document.getElementById('syncPan');
|
||||
var panRate = document.getElementById('panRate');
|
||||
var panRateLabel = document.getElementById('panRateLabel');
|
||||
var cueRate = document.getElementById('cueRate');
|
||||
var cueRateLabel = document.getElementById('cueRateLabel');
|
||||
var volume = document.getElementById('volume');
|
||||
var volumeLabel = document.getElementById('volumeLabel');
|
||||
var testSound = document.getElementById('testSound');
|
||||
var connectHID = document.getElementById('connectHID');
|
||||
var deviceStatus = document.getElementById('deviceStatus');
|
||||
var knobSens = document.getElementById('knobSens');
|
||||
var knobSensLabel = document.getElementById('knobSensLabel');
|
||||
var hidDevice = null;
|
||||
var audioFiles = [];
|
||||
var patientName = document.getElementById('patientName');
|
||||
var savePatientBtn = document.getElementById('savePatient');
|
||||
var sessionCountEl = document.getElementById('sessionCount');
|
||||
var lastSessionInfoEl = document.getElementById('lastSessionInfo');
|
||||
var clearSessionsBtn = document.getElementById('clearSessions');
|
||||
var deletePatientBtn = document.getElementById('deletePatient');
|
||||
function setActive(group, dataKey, stateKey){
|
||||
var btns = group.querySelectorAll('button');
|
||||
for(var i=0;i<btns.length;i++){
|
||||
var b = btns[i];
|
||||
var isActive = (b.dataset[dataKey] === state[stateKey]);
|
||||
if(isActive){ b.classList.add('active') } else { b.classList.remove('active') }
|
||||
}
|
||||
}
|
||||
function updateTimerLabel(){ var m = Math.max(0, parseInt(timerMinutes.value||'0',10)); timerDisplay.textContent = String(m).padStart(2,'0')+':00'; }
|
||||
function updateSpeedLabel(){ speedLabel.textContent = speed.value+' px/s'; }
|
||||
function updateSizeLabel(){ sizeLabel.textContent = size.value+' px'; }
|
||||
function updateGlowIntensityLabel(){ glowIntensityLabel.textContent = glowIntensity.value+'%'; }
|
||||
function updateGlowRateLabel(){ glowRateLabel.textContent = parseFloat(glowRate.value).toFixed(1)+' Hz'; }
|
||||
function updateYPercentLabel(){ yPercentLabel.textContent = yPercent.value+'%'; }
|
||||
function updateRampLabel(){ rampLabel.textContent = rampSeconds.value+' s'; }
|
||||
function updateEdgePauseLabel(){ edgePauseLabel.textContent = edgePause.value+' ms'; }
|
||||
function updateWiggleLabel(){ wiggleLabel.textContent = wiggleAmplitude.value+' px'; }
|
||||
function updatePanRateLabel(){ panRateLabel.textContent = parseFloat(panRate.value).toFixed(1)+' Hz' }
|
||||
function updateCueRateLabel(){ cueRateLabel.textContent = parseFloat(cueRate.value).toFixed(1)+' Hz' }
|
||||
function updateVolumeLabel(){ volumeLabel.textContent = volume.value+'%' }
|
||||
function refreshAudioMode(){ var m = audioMode.value; programControls.style.display = (m==='programmatic' ? 'flex' : 'none'); fileControls.style.display = (m==='file' ? 'flex' : 'none') }
|
||||
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 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 getPatient(name){ var n = (name||'').trim()||'Unnamed'; var m = loadPatients(); if(!m[n]) m[n] = { name:n, sessions:[], count:0, last:null }; return m[n] }
|
||||
function setPatient(p){ var m = loadPatients(); m[p.name] = p; 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('') } } }
|
||||
function recordSession(){ var n = (patientName.value||'').trim()||'Unnamed'; var st = BLS.loadState(); if(!st.startedAt) return; var end = Date.now(); var durationSec = Math.floor((end - st.startedAt)/1000); var p = getPatient(n); p.sessions.push({ start: st.startedAt, end: end, durationSec: durationSec }); p.count = (p.count||0)+1; p.last = { start: st.startedAt, end: end, durationSec: durationSec }; setPatient(p); refreshPatientUI() }
|
||||
function refreshPresetList(){ var names = BLS.listPresets(); var html=''; for(var i=0;i<names.length;i++){ var n=names[i]; html += '<option value="'+n+'">'+n+'</option>' } presetList.innerHTML = html }
|
||||
function applyState(next, broadcast){ state = BLS.mergeState(state, next||{}); renderer.setState(state); if(state.running){ renderer.start() } else { renderer.stop() } if(broadcast){ chan.send(state) } }
|
||||
shapeControls.addEventListener('click', function(e){ var d = e.target.dataset.shape; if(!d) return; applyState({shape:d}, true); setActive(shapeControls,'shape','shape'); });
|
||||
directionControls.addEventListener('click', function(e){ var d = e.target.dataset.direction; if(!d) return; applyState({direction:d}, true); setActive(directionControls,'direction','direction'); });
|
||||
easingControls.addEventListener('click', function(e){ var d = e.target.dataset.easing; if(!d) return; applyState({easingMode:d}, true); setActive(easingControls,'easing','easingMode'); });
|
||||
shapeColor.addEventListener('input', function(){ applyState({shapeColor:shapeColor.value}, true); });
|
||||
bgColor.addEventListener('input', function(){ applyState({bgColor:bgColor.value}, true); });
|
||||
shapePreset.addEventListener('click', function(e){ var c = e.target.dataset.color; if(!c) return; shapeColor.value = c; applyState({shapeColor:c}, true); });
|
||||
bgPreset.addEventListener('click', function(e){ var c = e.target.dataset.color; if(!c) return; bgColor.value = c; applyState({bgColor:c}, true); });
|
||||
speed.addEventListener('input', function(){ updateSpeedLabel(); var m = (BLS.loadState().speedMultiplier||1); applyState({speed:parseInt(speed.value,10)*m}, true); });
|
||||
size.addEventListener('input', function(){ updateSizeLabel(); applyState({size:parseInt(size.value,10)}, true); });
|
||||
yPercent.addEventListener('input', function(){ updateYPercentLabel(); applyState({yPercent:parseInt(yPercent.value,10)}, true); });
|
||||
yReset.addEventListener('click', function(){ yPercent.value=50; updateYPercentLabel(); applyState({yPercent:50}, true); });
|
||||
glowEnable.addEventListener('change', function(){ applyState({glowEnabled: glowEnable.checked}, true) });
|
||||
glowIntensity.addEventListener('input', function(){ updateGlowIntensityLabel(); var f = parseInt(glowIntensity.value,10)/100; applyState({glowIntensity:f}, true) });
|
||||
glowRate.addEventListener('input', function(){ updateGlowRateLabel(); applyState({glowRate: parseFloat(glowRate.value)}, true) });
|
||||
rampEnable.addEventListener('change', function(){ var st = BLS.loadState(); var next = {rampEnabled: rampEnable.checked}; if(!rampEnable.checked){ next.easingMode = 'linear' } if(st.running && rampEnable.checked){ next.startedAt = Date.now() } applyState(next, true) });
|
||||
rampSeconds.addEventListener('input', function(){ updateRampLabel(); var st = BLS.loadState(); var next = {rampSeconds: parseInt(rampSeconds.value,10)}; if(st.running && st.rampEnabled){ next.startedAt = Date.now() } applyState(next, true) });
|
||||
edgePause.addEventListener('input', function(){ updateEdgePauseLabel(); applyState({edgePauseMs: parseInt(edgePause.value,10)}, true) });
|
||||
wiggleEnable.addEventListener('change', function(){ applyState({wiggleEnabled: wiggleEnable.checked}, true) });
|
||||
wiggleAmplitude.addEventListener('input', function(){ updateWiggleLabel(); applyState({wiggleAmplitude: parseInt(wiggleAmplitude.value,10)}, true) });
|
||||
timerMinutes.addEventListener('input', function(){ updateTimerLabel(); });
|
||||
savePresetBtn.addEventListener('click', function(){ var n = (presetName.value||'').trim(); if(!n) return; BLS.savePreset(n, BLS.loadState()); refreshPresetList() })
|
||||
applyPresetBtn.addEventListener('click', function(){ var n = presetList.value; if(!n) return; var preset = BLS.loadPreset(n); if(!preset) return; applyState(preset, true); setActive(shapeControls,'shape','shape'); setActive(directionControls,'direction','direction'); setActive(easingControls,'easing','easingMode') })
|
||||
deletePresetBtn.addEventListener('click', function(){ var n = presetList.value; if(!n) return; BLS.deletePreset(n); refreshPresetList() })
|
||||
exportPresetsBtn.addEventListener('click', async function(){ var data = BLS.exportPresets(); if(window.showSaveFilePicker){ try{ var handle = await window.showSaveFilePicker({ suggestedName: 'bls-presets.json', types:[{ description:'JSON', accept:{ 'application/json':['.json'] } }] }); var w = await handle.createWritable(); await w.write(data); await w.close() }catch(e){} } else { var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([data], {type:'application/json'})); a.download = 'bls-presets.json'; a.click(); URL.revokeObjectURL(a.href) } })
|
||||
importPresetsBtn.addEventListener('click', async function(){ if(window.showOpenFilePicker){ try{ var files = await window.showOpenFilePicker({ multiple:false, types:[{ description:'JSON', accept:{ 'application/json':['.json'] } }] }); if(files && files.length){ var file = await files[0].getFile(); var text = await file.text(); BLS.importPresets(text); refreshPresetList() } }catch(e){} } else { var input = document.createElement('input'); input.type='file'; input.accept='.json,application/json'; input.addEventListener('change', function(){ var f = input.files && input.files[0]; if(!f) return; var reader = new FileReader(); reader.onload = function(){ BLS.importPresets(reader.result); refreshPresetList() }; reader.readAsText(f) }); input.click() } })
|
||||
async function listAudioFromHandle(handle){ audioFiles=[]; if(!handle) return; try{ for await (const [name, h] of handle.entries()){ if(h.kind==='file' && /\.(mp3|wav)$/i.test(name)){ audioFiles.push({name:name, handle:h}) } } }catch(e){} audioSelect.innerHTML = '<option value="">No audio</option>' + audioFiles.map(function(f){ return '<option value="'+f.name+'">'+f.name+'</option>' }).join('') }
|
||||
async function pickFolder(){ if(window.showDirectoryPicker){ try{ var dir = await window.showDirectoryPicker(); await listAudioFromHandle(dir); }catch(e){} } else { var input = document.createElement('input'); input.type='file'; input.webkitdirectory=true; input.multiple=true; input.addEventListener('change', function(){ audioFiles = Array.from(input.files).filter(function(f){ return /\.(mp3|wav)$/i.test(f.name) }).map(function(f){ return {name:f.name, file:f} }); audioSelect.innerHTML = '<option value="">No audio</option>' + audioFiles.map(function(f){ return '<option value="'+f.name+'">'+f.name+'</option>' }).join(''); }); input.click(); } }
|
||||
pickAudioFolder.addEventListener('click', pickFolder);
|
||||
audioSelect.addEventListener('change', function(){ var name = audioSelect.value || null; applyState({audioName:name}, true) });
|
||||
audioEnable.addEventListener('change', function(){ applyState({audioEnabled: audioEnable.checked}, true) });
|
||||
audioMode.addEventListener('change', function(){ refreshAudioMode(); applyState({audioMode: audioMode.value}, true) });
|
||||
programPreset.addEventListener('change', function(){ applyState({programPreset: programPreset.value}, true) });
|
||||
syncPan.addEventListener('change', function(){ applyState({syncPan: syncPan.checked}, true) });
|
||||
panRate.addEventListener('input', function(){ updatePanRateLabel(); applyState({panRate: parseFloat(panRate.value)}, true) });
|
||||
cueRate.addEventListener('input', function(){ updateCueRateLabel(); applyState({cueRate: parseFloat(cueRate.value)}, true) });
|
||||
volume.addEventListener('input', function(){ updateVolumeLabel(); applyState({volume: parseInt(volume.value,10)/100}, true) });
|
||||
testSound.addEventListener('click', function(){ try{ var st = BLS.loadState(); if(st.audioMode==='programmatic'){ AudioEngine.setVolume(st.volume||0.6); var pan = 0; if(st.programPreset==='click'){ AudioEngine.playClick(pan) } else if(st.programPreset==='ping'){ AudioEngine.playPing(pan) } else if(st.programPreset==='woodblock'){ AudioEngine.playWoodblock(pan) } else if(st.programPreset==='bell'){ AudioEngine.playBell(pan) } else if(st.programPreset==='bass'){ AudioEngine.playBass(pan) } else if(st.programPreset==='kick'){ AudioEngine.playKick(pan) } else if(st.programPreset==='snare'){ AudioEngine.playSnare(pan) } else if(st.programPreset==='hihat'){ AudioEngine.playHiHat(pan) } else if(st.programPreset==='sweep'){ AudioEngine.playSweep(pan) } else if(st.programPreset==='zap'){ AudioEngine.playZap(pan) } else if(st.programPreset==='bubble'){ AudioEngine.playBubble(pan) } else if(st.programPreset==='pink'){ AudioEngine.startPinkNoisePan(st.panRate||0.5); setTimeout(function(){ AudioEngine.stopNoise() }, 800) } else if(st.programPreset==='hybrid'){ AudioEngine.startHybrid(st.cueRate||1.2); setTimeout(function(){ AudioEngine.stopNoise() }, 1000) } } }catch(e){} });
|
||||
knobSens.addEventListener('input', function(){ updateKnobSensLabel() });
|
||||
if(clearSessionsBtn){ clearSessionsBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); p.sessions = []; p.count = 0; p.last = null; setPatient(p); refreshPatientUI() }) }
|
||||
if(deletePatientBtn){ deletePatientBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; 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 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) }) }
|
||||
savePatientBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); setPatient(p); refreshPatientUI() })
|
||||
async function connectHIDDevice(){ if(!navigator.hid){ deviceStatus.textContent = 'HID not supported'; return } try{ var devices = await navigator.hid.requestDevice({filters: []}); if(!devices || !devices.length){ deviceStatus.textContent = 'No device selected'; return } hidDevice = devices[0]; await hidDevice.open(); deviceStatus.textContent = 'Connected: '+(hidDevice.productName||'HID'); hidDevice.addEventListener('inputreport', function(e){ var data = e.data; if(!data) return; var b0 = 0; try{ b0 = data.getInt8(0) }catch(_){ try{ b0 = data.getUint8(0); if(b0>127) b0 = b0-256 }catch(_){ b0 = 0 } } var sens = parseInt(knobSens.value,10)||1; var delta = b0 * sens; if(delta!==0){ var current = parseInt(speed.value,10)||250; var next = Math.max(parseInt(speed.min,10), Math.min(parseInt(speed.max,10), current + delta)); speed.value = String(next); updateSpeedLabel(); applyState({speed: next}, true) } }) }catch(e){ deviceStatus.textContent = 'Error connecting' } }
|
||||
connectHID.addEventListener('click', connectHIDDevice);
|
||||
var tickId = null;
|
||||
function startTimer(){ if(tickId) clearInterval(tickId); var durationSec = Math.max(0, (parseInt(timerMinutes.value||'0',10)||0)*60); var startedAt = Date.now(); applyState({running:true, durationSec:durationSec, startedAt:startedAt}, true); statusText.textContent = 'Running'; tickId = setInterval(function(){ var st = BLS.loadState(); if(!st.running){ clearInterval(tickId); tickId=null; return } var elapsed = Math.floor((Date.now()-st.startedAt)/1000); var rem = Math.max(0, st.durationSec - elapsed); var em = Math.floor(elapsed/60), es = elapsed%60; elapsedText.textContent = String(em).padStart(2,'0')+':'+String(es).padStart(2,'0'); var rm = Math.floor(rem/60); timerDisplay.textContent = String(rm).padStart(2,'0')+':'+String(rem%60).padStart(2,'0'); if(rem<=0){ stopTimer() } }, 250); }
|
||||
function stopTimer(){ if(tickId){ clearInterval(tickId); tickId=null } recordSession(); applyState({running:false}, true); statusText.textContent = 'Idle'; refreshPatientUI() }
|
||||
startBtn.addEventListener('click', startTimer);
|
||||
endBtn.addEventListener('click', stopTimer);
|
||||
openPatient.addEventListener('click', function(){ window.open('patient.html','bls-patient'); });
|
||||
renderer.resize(360,220);
|
||||
updateSpeedLabel(); updateSizeLabel(); updateYPercentLabel(); updateTimerLabel(); updateGlowIntensityLabel(); updateGlowRateLabel(); updateRampLabel(); updateEdgePauseLabel(); updateWiggleLabel(); updatePanRateLabel(); updateCueRateLabel(); updateVolumeLabel(); statusText.textContent = state.running? 'Running':'Idle'; setActive(shapeControls,'shape','shape'); setActive(directionControls,'direction','direction'); setActive(easingControls,'easing','easingMode');
|
||||
audioEnable.checked = !!state.audioEnabled; if(state.audioName){ var opt = document.createElement('option'); opt.value = state.audioName; opt.textContent = state.audioName; audioSelect.appendChild(opt); audioSelect.value = state.audioName }
|
||||
glowEnable.checked = !!state.glowEnabled;
|
||||
glowIntensity.value = Math.round((state.glowIntensity||1)*100);
|
||||
rampEnable.checked = !!state.rampEnabled;
|
||||
rampSeconds.value = parseInt(state.rampSeconds||10,10);
|
||||
edgePause.value = parseInt(state.edgePauseMs||0,10);
|
||||
wiggleEnable.checked = !!state.wiggleEnabled;
|
||||
wiggleAmplitude.value = parseInt(state.wiggleAmplitude||0,10);
|
||||
audioMode.value = state.audioMode || 'programmatic';
|
||||
programPreset.value = state.programPreset || 'pink';
|
||||
syncPan.checked = !!state.syncPan;
|
||||
panRate.value = String(state.panRate||0.5);
|
||||
cueRate.value = String(state.cueRate||1.2);
|
||||
volume.value = String(Math.round((state.volume||0.6)*100));
|
||||
glowRate.value = String(state.glowRate||0.5);
|
||||
refreshAudioMode();
|
||||
updateKnobSensLabel();
|
||||
yPercent.value = parseInt(state.yPercent||50,10);
|
||||
updateYPercentLabel();
|
||||
refreshPresetList(); refreshPatientList();
|
||||
refreshPatientUI();
|
||||
}
|
||||
function waitForBLS(){ if(window.BLS){ initAdmin() } else { setTimeout(waitForBLS, 50) } }
|
||||
if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', waitForBLS) } else { waitForBLS() }
|
||||
450
js/audio.js
Normal file
450
js/audio.js
Normal file
@ -0,0 +1,450 @@
|
||||
;(function(){
|
||||
function hasStereoPanner(ctx){ return !!(ctx && ctx.createStereoPanner) }
|
||||
function makePanner(ctx){
|
||||
if(hasStereoPanner(ctx)) return ctx.createStereoPanner();
|
||||
var gainL = ctx.createGain(); var gainR = ctx.createGain(); var merger = ctx.createChannelMerger(2);
|
||||
gainL.connect(merger, 0, 0); gainR.connect(merger, 0, 1);
|
||||
var node = {
|
||||
_ctx: ctx, _l: gainL, _r: gainR, _merger: merger,
|
||||
connect: function(dest){ merger.connect(dest) },
|
||||
pan: { value: 0 },
|
||||
setPan: function(p){ var pan = Math.max(-1, Math.min(1, p||0)); var l = Math.cos((pan+1)*Math.PI/4); var r = Math.sin((pan+1)*Math.PI/4); this._l.gain.setValueAtTime(l, ctx.currentTime); this._r.gain.setValueAtTime(r, ctx.currentTime); this.pan.value = pan }
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
var AudioEngine = {
|
||||
ctx: null,
|
||||
master: null,
|
||||
noiseNodes: [],
|
||||
panInterval: null,
|
||||
|
||||
ensureCtx: function(){
|
||||
if(!this.ctx){
|
||||
this.ctx = new (window.AudioContext||window.webkitAudioContext)();
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = 0.6;
|
||||
this.master.connect(this.ctx.destination)
|
||||
}
|
||||
if(this.ctx.state==='suspended'){ this.ctx.resume() }
|
||||
return this.ctx
|
||||
},
|
||||
|
||||
setVolume: function(v){
|
||||
var ctx = this.ensureCtx();
|
||||
var val = Math.max(0, Math.min(1, v||0.6));
|
||||
this.master.gain.setValueAtTime(val, ctx.currentTime)
|
||||
},
|
||||
|
||||
// Enhanced original sounds
|
||||
playClick: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
var filter = ctx.createBiquadFilter();
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0); else if(p.pan) p.pan.value = pan||0;
|
||||
|
||||
osc.type='square';
|
||||
osc.frequency.setValueAtTime(1200, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(800, ctx.currentTime+0.02);
|
||||
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 3000;
|
||||
filter.Q.value = 2;
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.5, ctx.currentTime+0.003);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.025);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime+0.05)
|
||||
},
|
||||
|
||||
playPing: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var osc = ctx.createOscillator();
|
||||
var osc2 = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
var filter = ctx.createBiquadFilter();
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
osc.type='sine';
|
||||
osc.frequency.setValueAtTime(1600, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(800, ctx.currentTime+0.15);
|
||||
|
||||
osc2.type='sine';
|
||||
osc2.frequency.setValueAtTime(2400, ctx.currentTime);
|
||||
osc2.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime+0.15);
|
||||
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 4000;
|
||||
filter.Q.value = 1;
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.4, ctx.currentTime+0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.2);
|
||||
|
||||
osc.connect(filter);
|
||||
osc2.connect(filter);
|
||||
filter.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
osc.start();
|
||||
osc2.start();
|
||||
osc.stop(ctx.currentTime+0.25);
|
||||
osc2.stop(ctx.currentTime+0.25);
|
||||
},
|
||||
|
||||
playWoodblock: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var buffer = ctx.createBuffer(1, 44100, 44100);
|
||||
var data = buffer.getChannelData(0);
|
||||
for(var i=0;i<data.length;i++){ data[i] = (Math.random()*2-1) * Math.exp(-i/800) }
|
||||
|
||||
var noise = ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
var filter = ctx.createBiquadFilter();
|
||||
filter.type='bandpass';
|
||||
filter.frequency.value=1800;
|
||||
filter.Q.value = 8;
|
||||
var gain = ctx.createGain();
|
||||
gain.gain.value=0.6;
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
noise.connect(filter).connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
noise.start();
|
||||
noise.stop(ctx.currentTime+0.05)
|
||||
},
|
||||
|
||||
// NEW SOUNDS
|
||||
playBell: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var oscs = [1, 2.4, 3.2, 4.8].map(function(ratio){
|
||||
var osc = ctx.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = 440 * ratio;
|
||||
return osc;
|
||||
});
|
||||
|
||||
var gain = ctx.createGain();
|
||||
var filter = ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 4000;
|
||||
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.3, ctx.currentTime+0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.8);
|
||||
|
||||
oscs.forEach(function(osc){
|
||||
osc.connect(filter);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime+1);
|
||||
});
|
||||
|
||||
filter.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
},
|
||||
|
||||
playBass: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
var filter = ctx.createBiquadFilter();
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(80, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(60, ctx.currentTime+0.15);
|
||||
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(500, ctx.currentTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(100, ctx.currentTime+0.15);
|
||||
filter.Q.value = 3;
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.5, ctx.currentTime+0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.2);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime+0.25);
|
||||
},
|
||||
|
||||
playKick: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(150, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(40, ctx.currentTime+0.15);
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.8, ctx.currentTime+0.005);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.2);
|
||||
|
||||
osc.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime+0.25);
|
||||
},
|
||||
|
||||
playSnare: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var buffer = ctx.createBuffer(1, 44100*0.2, 44100);
|
||||
var data = buffer.getChannelData(0);
|
||||
for(var i=0;i<data.length;i++){
|
||||
data[i] = (Math.random()*2-1) * Math.exp(-i/2000);
|
||||
}
|
||||
|
||||
var noise = ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
|
||||
var osc = ctx.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = 180;
|
||||
|
||||
var noiseFilter = ctx.createBiquadFilter();
|
||||
noiseFilter.type = 'highpass';
|
||||
noiseFilter.frequency.value = 2000;
|
||||
|
||||
var gain = ctx.createGain();
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.6, ctx.currentTime+0.005);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.12);
|
||||
|
||||
noise.connect(noiseFilter);
|
||||
noiseFilter.connect(gain);
|
||||
osc.connect(gain);
|
||||
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
|
||||
noise.start();
|
||||
osc.start();
|
||||
noise.stop(ctx.currentTime+0.15);
|
||||
osc.stop(ctx.currentTime+0.15);
|
||||
},
|
||||
|
||||
playHiHat: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var buffer = ctx.createBuffer(1, 44100*0.05, 44100);
|
||||
var data = buffer.getChannelData(0);
|
||||
for(var i=0;i<data.length;i++){
|
||||
data[i] = (Math.random()*2-1) * Math.exp(-i/500);
|
||||
}
|
||||
|
||||
var noise = ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
|
||||
var filter = ctx.createBiquadFilter();
|
||||
filter.type = 'highpass';
|
||||
filter.frequency.value = 7000;
|
||||
filter.Q.value = 1;
|
||||
|
||||
var gain = ctx.createGain();
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.05);
|
||||
|
||||
noise.connect(filter);
|
||||
filter.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
|
||||
noise.start();
|
||||
noise.stop(ctx.currentTime+0.06);
|
||||
},
|
||||
|
||||
playSweep: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
var filter = ctx.createBiquadFilter();
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(200, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(2000, ctx.currentTime+0.4);
|
||||
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(300, ctx.currentTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(3000, ctx.currentTime+0.4);
|
||||
filter.Q.value = 5;
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.3, ctx.currentTime+0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.5);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime+0.55);
|
||||
},
|
||||
|
||||
playZap: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
var filter = ctx.createBiquadFilter();
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
osc.type = 'square';
|
||||
osc.frequency.setValueAtTime(800, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(50, ctx.currentTime+0.08);
|
||||
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(2000, ctx.currentTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(200, ctx.currentTime+0.08);
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.4, ctx.currentTime+0.005);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.1);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime+0.12);
|
||||
},
|
||||
|
||||
playBubble: function(pan){
|
||||
var ctx = this.ensureCtx();
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(pan||0);
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(600, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(300, ctx.currentTime+0.05);
|
||||
osc.frequency.exponentialRampToValueAtTime(400, ctx.currentTime+0.1);
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.3, ctx.currentTime+0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.15);
|
||||
|
||||
osc.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime+0.2);
|
||||
},
|
||||
|
||||
// Noise functions
|
||||
createWhiteNoise: function(){
|
||||
var ctx = this.ensureCtx();
|
||||
var bufferSize = 2*ctx.sampleRate;
|
||||
var buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
var data = buffer.getChannelData(0);
|
||||
for(var i=0;i<bufferSize;i++){ data[i] = Math.random()*2-1 }
|
||||
var noise = ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
noise.loop = true;
|
||||
return noise
|
||||
},
|
||||
|
||||
createPinkNoise: function(){
|
||||
var ctx = this.ensureCtx();
|
||||
var node = ctx.createScriptProcessor(4096,1,1);
|
||||
var b0=0,b1=0,b2=0,b3=0,b4=0,b5=0,b6=0;
|
||||
node.onaudioprocess=function(e){
|
||||
var out=e.outputBuffer.getChannelData(0);
|
||||
for(var i=0;i<out.length;i++){
|
||||
var white=Math.random()*2-1;
|
||||
b0=0.99886*b0+white*0.0555179;
|
||||
b1=0.99332*b1+white*0.0750759;
|
||||
b2=0.969*b2+white*0.153852;
|
||||
b3=0.8665*b3+white*0.3104856;
|
||||
b4=0.55*b4+white*0.5329522;
|
||||
b5=-0.7616*b5-white*0.016898;
|
||||
out[i]=(b0+b1+b2+b3+b4+b5+b6+white*0.5362)*0.1;
|
||||
b6=white*0.115926
|
||||
}
|
||||
};
|
||||
return node
|
||||
},
|
||||
|
||||
startPinkNoisePan: function(speedHz){
|
||||
this.stopNoise();
|
||||
var ctx=this.ensureCtx();
|
||||
var noise=this.createPinkNoise();
|
||||
var p=makePanner(ctx);
|
||||
noise.connect(p._l? p._l : p);
|
||||
if(p._r) noise.connect(p._r);
|
||||
p.connect(this.master);
|
||||
if(noise.start) noise.start();
|
||||
this.noiseNodes.push(noise);
|
||||
var rate=speedHz||0.5;
|
||||
var start=ctx.currentTime;
|
||||
var self=this;
|
||||
this.panInterval=setInterval(function(){
|
||||
var t=ctx.currentTime-start;
|
||||
var pan=Math.sin(2*Math.PI*rate*t);
|
||||
if(p.setPan) p.setPan(pan);
|
||||
else if(p.pan) p.pan.value = pan
|
||||
},20)
|
||||
},
|
||||
|
||||
startHybrid: function(speedHz){
|
||||
this.stopNoise();
|
||||
var ctx=this.ensureCtx();
|
||||
var noise=this.createPinkNoise();
|
||||
var p=makePanner(ctx);
|
||||
noise.connect(p._l? p._l : p);
|
||||
if(p._r) noise.connect(p._r);
|
||||
p.connect(this.master);
|
||||
if(noise.start) noise.start();
|
||||
this.noiseNodes.push(noise);
|
||||
var rate=speedHz||1.2;
|
||||
var left=true;
|
||||
var self=this;
|
||||
this.panInterval=setInterval(function(){
|
||||
var pan=left? -1:1;
|
||||
if(p.setPan) p.setPan(pan);
|
||||
else if(p.pan) p.pan.value=pan;
|
||||
self.playWoodblock(pan);
|
||||
left=!left
|
||||
}, (1000/Math.max(0.1,rate)))
|
||||
},
|
||||
|
||||
stopNoise: function(){
|
||||
try{ clearInterval(this.panInterval) }catch(e){}
|
||||
this.panInterval=null;
|
||||
this.noiseNodes.forEach(function(n){ try{ n.disconnect() }catch(e){} });
|
||||
this.noiseNodes=[]
|
||||
}
|
||||
}
|
||||
|
||||
window.AudioEngine = AudioEngine
|
||||
})();
|
||||
26
js/bls.js
Normal file
26
js/bls.js
Normal file
@ -0,0 +1,26 @@
|
||||
;(function(){
|
||||
function Channel(name){ this.name = name; this.handlers = []; }
|
||||
Channel.prototype.on = function(handler){ this.handlers.push(handler) };
|
||||
Channel.prototype.send = function(payload){ try{ localStorage.setItem('bls_session', JSON.stringify({name:this.name, payload:payload, t:Date.now()})) }catch(e){} };
|
||||
window.addEventListener('storage', function(e){ if(e.key!=='bls_session') return; try{ var data = JSON.parse(e.newValue||'{}'); if(!data) return; if(Channel._handlers){ var hs = Channel._handlers[data.name]||[]; for(var i=0;i<hs.length;i++){ hs[i](data.payload) } } }catch(e){} });
|
||||
Channel._handlers = {};
|
||||
Channel.prototype.on = function(handler){ if(!Channel._handlers[this.name]) Channel._handlers[this.name] = []; Channel._handlers[this.name].push(handler) };
|
||||
var defaultState = { shape:'circle', shapeColor:'#00ff88', bgColor:'#000000', direction:'lr', speed:250, speedMultiplier:1, size:28, yPercent:50, glowEnabled:false, glowIntensity:1, glowRate:0.5, rampEnabled:false, rampSeconds:10, easingMode:'linear', edgePauseMs:0, wiggleEnabled:false, wiggleAmplitude:0, audioMode:'programmatic', programPreset:'pink', syncPan:true, panRate:0.5, cueRate:1.2, volume:0.6, audioName:null, audioEnabled:false, running:false, durationSec:600, startedAt:null };
|
||||
function loadState(){ try{ var s = JSON.parse(localStorage.getItem('bls_state')||'null'); if(!s) return Object.assign({}, defaultState); return Object.assign({}, defaultState, s) }catch(e){ return Object.assign({}, defaultState) } }
|
||||
function saveState(s){ try{ localStorage.setItem('bls_state', JSON.stringify(s)) }catch(e){} }
|
||||
function mergeState(prev, next){ var s = Object.assign({}, prev, next); saveState(s); return s }
|
||||
function presetKeys(){ return ['shape','shapeColor','bgColor','direction','speed','size','yPercent','glowEnabled','glowIntensity','glowRate','rampEnabled','rampSeconds','easingMode','edgePauseMs','wiggleEnabled','wiggleAmplitude','audioMode','programPreset','syncPan','panRate','cueRate','volume','audioName','audioEnabled'] }
|
||||
function sanitizePreset(s){ var out={}; var keys=presetKeys(); for(var i=0;i<keys.length;i++){ var k=keys[i]; if(Object.prototype.hasOwnProperty.call(s,k)) out[k]=s[k] } return out }
|
||||
function loadPresets(){ try{ var m = JSON.parse(localStorage.getItem('bls_presets')||'null'); return m&&typeof m==='object'? m : {} }catch(e){ return {} } }
|
||||
function savePresets(m){ try{ localStorage.setItem('bls_presets', JSON.stringify(m)) }catch(e){} }
|
||||
function Renderer(canvas){ var ctx = canvas.getContext('2d'); var state = loadState(); var pos=null; var dir={x:1,y:0}; var running=false; var onUpdate=null; var pauseMs=0; var wigglePhase=0; function setDir(){ if(state.direction==='lr'){dir.x=1;dir.y=0}else if(state.direction==='ud'){dir.x=0;dir.y=1}else if(state.direction==='diag_down'){dir.x=1;dir.y=1}else{dir.x=1;dir.y=-1} var len=Math.sqrt(dir.x*dir.x+dir.y*dir.y); dir.x/=len; dir.y/=len } function yFromPercent(h,r,p){ return Math.max(r, Math.min(h-r, r + (h-2*r) * (p/100))) } function ensurePos(){ var w=canvas.width,h=canvas.height,r=state.size; if(!pos){ if(state.direction==='lr'){pos={x:r+2,y:yFromPercent(h,r,state.yPercent||50)}} else if(state.direction==='ud'){pos={x:w/2,y:yFromPercent(h,r,state.yPercent||5)}} else if(state.direction==='diag_down'){pos={x:r+2,y:yFromPercent(h,r,state.yPercent||5)}} else {pos={x:r+2,y:yFromPercent(h,r,state.yPercent||95)}} } pos.x=Math.max(r,Math.min(w-r,pos.x)); pos.y=Math.max(r,Math.min(h-r,pos.y)) } function clear(){ ctx.fillStyle=state.bgColor; ctx.fillRect(0,0,canvas.width,canvas.height) } function draw(){ var r=state.size; var x=pos.x,y=pos.y; var doGlow = !!state.glowEnabled; var gi = state.glowIntensity || 1; var t = Date.now()/1000; var freq = Math.max(0.05, state.glowRate||0.5); var a = 0.5 + 0.5*Math.sin(t*2*Math.PI*freq); var hue = (t*60*freq)%360; var wx=0, wy=0; if(state.wiggleEnabled && state.wiggleAmplitude>0){ wigglePhase += 0.04 + state.speed/4000; var s = Math.sin(wigglePhase); if(state.direction==='lr'){ wy = s * state.wiggleAmplitude } else if(state.direction==='ud'){ wx = s * state.wiggleAmplitude } else { wx = -dir.y * s * state.wiggleAmplitude; wy = dir.x * s * state.wiggleAmplitude } } if(doGlow){ ctx.save(); ctx.shadowBlur = r * gi * (0.7 + a); ctx.shadowColor = 'hsla('+hue+',100%,60%,0.9)' } ctx.fillStyle=state.shapeColor; if(state.shape==='circle'){ ctx.beginPath(); ctx.arc(x+wx,y+wy,r,0,Math.PI*2); ctx.closePath(); ctx.fill() } else if(state.shape==='square'){ ctx.fillRect(x-r+wx,y-r+wy,r*2,r*2) } else { var h2=r*1.6; ctx.beginPath(); ctx.moveTo(x+wx,y-h2/2+wy); ctx.lineTo(x-r+wx,y+h2/2+wy); ctx.lineTo(x+r+wx,y+h2/2+wy); ctx.closePath(); ctx.fill() } if(doGlow){ ctx.restore() } } function effectiveSpeed(w,h,r){ var base = state.speed; var ramp = 1; if(state.rampEnabled && state.startedAt && state.rampSeconds>0){ var dt = Math.max(0,(Date.now()-state.startedAt)/1000); var k = Math.min(1, dt/state.rampSeconds); ramp = 0.2 + 0.8*k } var s=1; if(state.easingMode==='ease'){ if(dir.x!==0){ var u = (pos.x - r)/(w - 2*r); s = 0.3 + 0.7*(0.5 - 0.5*Math.cos(2*Math.PI*Math.max(0,Math.min(1,u)))) } else { var v = (pos.y - r)/(h - 2*r); s = 0.3 + 0.7*(0.5 - 0.5*Math.cos(2*Math.PI*Math.max(0,Math.min(1,v)))) } } return base * ramp * s } function step(dt){ var w=canvas.width,h=canvas.height,r=state.size; if(pauseMs>0){ pauseMs = Math.max(0, pauseMs - dt*1000); return } var sp = effectiveSpeed(w,h,r); pos.x += dir.x * sp * dt; pos.y += dir.y * sp * dt; var hit=false; if(pos.x <= r){ pos.x = r; dir.x = Math.abs(dir.x); hit=true } if(pos.x >= w - r){ pos.x = w - r; dir.x = -Math.abs(dir.x); hit=true } if(pos.y <= r){ pos.y = r; dir.y = Math.abs(dir.y); hit=true } if(pos.y >= h - r){ pos.y = h - r; dir.y = -Math.abs(dir.y); hit=true } if(hit && state.edgePauseMs>0){ pauseMs = state.edgePauseMs } if(onUpdate){ onUpdate({x:pos.x,y:pos.y,w:w,h:h}) } } var last=0; function loop(t){ if(!running){ return } if(!last) last=t; var dt=(t-last)/1000; last=t; clear(); draw(); step(dt); requestAnimationFrame(loop) } function start(){ if(!running){ running=true; last=0; requestAnimationFrame(loop) } } function stop(){ running=false } function setState(next){ var hadY = next && Object.prototype.hasOwnProperty.call(next,'yPercent'); state=mergeState(state,next||{}); setDir(); ensurePos(); if(hadY){ var r=state.size; pos.y = yFromPercent(canvas.height, r, state.yPercent||50) } clear(); draw() } function resize(w,h){ if(typeof w==='number') canvas.width=w; if(typeof h==='number') canvas.height=h; ensurePos(); clear(); draw() } function setOnUpdate(fn){ onUpdate=fn } setDir(); ensurePos(); clear(); draw(); return { start:start, stop:stop, setState:setState, resize:resize, getState:function(){return state}, setOnUpdate:setOnUpdate } }
|
||||
window.bls = { createChannel:function(name){ return new Channel(name) }, defaultState:defaultState, loadState:loadState, saveState:saveState, mergeState:mergeState, Renderer:Renderer,
|
||||
listPresets:function(){ var m=loadPresets(); return Object.keys(m).sort() },
|
||||
savePreset:function(name, st){ if(!name) return; var m=loadPresets(); m[name]=sanitizePreset(st||loadState()); savePresets(m) },
|
||||
loadPreset:function(name){ var m=loadPresets(); return m[name]||null },
|
||||
deletePreset:function(name){ var m=loadPresets(); if(m[name]){ delete m[name]; savePresets(m) } },
|
||||
exportPresets:function(){ return JSON.stringify(loadPresets(), null, 2) },
|
||||
importPresets:function(json){ try{ var data = typeof json==='string'? JSON.parse(json): json; if(!data||typeof data!=='object') return; var m=loadPresets(); for(var k in data){ if(Object.prototype.hasOwnProperty.call(data,k)) m[k]=sanitizePreset(data[k]) } savePresets(m) }catch(e){} }
|
||||
}
|
||||
window.BLS = window.bls
|
||||
})();
|
||||
52
patient.html
Normal file
52
patient.html
Normal file
@ -0,0 +1,52 @@
|
||||
BLS<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BLS Patient View</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { background: #000; display: flex; align-items: center; justify-content: center; }
|
||||
#canvas { display: block; }
|
||||
#overlay { position: fixed; top: 10px; left: 10px; right: 10px; display: flex; justify-content: space-between; align-items: center; color: #fff; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; opacity: 0.8; }
|
||||
#overlay button { padding: 8px 10px; border: none; border-radius: 6px; background: #1f6feb; color: #fff; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas"></canvas>
|
||||
|
||||
<script src="js/bls.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script>
|
||||
function initPatient(){
|
||||
var canvas = document.getElementById('canvas')
|
||||
function size(){ canvas.width = window.innerWidth; canvas.height = window.innerHeight }
|
||||
size(); window.addEventListener('resize', size)
|
||||
var renderer = BLS.Renderer(canvas)
|
||||
var chan = BLS.createChannel('bls-session')
|
||||
chan.on(function(msg){ if(!msg || typeof msg !== 'object') return; var apply = Object.assign({}, msg); if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' } if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 } renderer.setState(apply); var st = renderer.getState(); if(st.running){ renderer.start() } else { renderer.stop() } })
|
||||
renderer.setState(BLS.loadState())
|
||||
var st = renderer.getState(); if(st.running){ renderer.start() }
|
||||
var audioCtx = null, panner = null, gain = null, source = null, audioFolder = null, audioBuffer = null
|
||||
var audioUnlocked = false
|
||||
var pendingProgramState = null
|
||||
function bindUnlock(){ ['pointerdown','keydown','touchstart','mousemove'].forEach(function(ev){ window.addEventListener(ev, function once(){ try{ AudioEngine.ensureCtx(); audioUnlocked = true }catch(e){} if(pendingProgramState){ var st = pendingProgramState; pendingProgramState = null; startProgrammatic(st) } try{ window.removeEventListener(ev, once) }catch(_){ } }, { once:true }) }) }
|
||||
bindUnlock()
|
||||
var progRunning = false
|
||||
function stopProgrammatic(){ AudioEngine.stopNoise(); progRunning=false }
|
||||
function startProgrammatic(st){ if(!audioUnlocked){ pendingProgramState = st; bindUnlock(); return } AudioEngine.setVolume(st.volume||0.6); if(st.programPreset==='pink'){ if(st.syncPan){ renderer.setOnUpdate(function(info){ var pan=(info.x/info.w)*2-1; }); AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate||0.5)) } else { AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate||0.5)) } progRunning=true } else if(st.programPreset==='hybrid'){ AudioEngine.startHybrid(Math.max(0.1, st.cueRate||1.2)); progRunning=true } else { renderer.setOnUpdate(function(info){ var r = renderer.getState().size; var hitL = info.x<=r+0.1; var hitR = info.x>=info.w-r-0.1; var hitT = info.y<=r+0.1; var hitB = info.y>=info.h-r-0.1; if(hitL||hitR||hitT||hitB){ var pan = hitL? -1 : hitR? 1 : 0; var pre = st.programPreset; if(pre==='click'){ AudioEngine.playClick(pan) } else if(pre==='ping'){ AudioEngine.playPing(pan) } else if(pre==='woodblock'){ AudioEngine.playWoodblock(pan) } else if(pre==='bell'){ AudioEngine.playBell(pan) } else if(pre==='bass'){ AudioEngine.playBass(pan) } else if(pre==='kick'){ AudioEngine.playKick(pan) } else if(pre==='snare'){ AudioEngine.playSnare(pan) } else if(pre==='hihat'){ AudioEngine.playHiHat(pan) } else if(pre==='sweep'){ AudioEngine.playSweep(pan) } else if(pre==='zap'){ AudioEngine.playZap(pan) } else if(pre==='bubble'){ AudioEngine.playBubble(pan) } else { AudioEngine.playWoodblock(pan) } } }); progRunning=true } }
|
||||
function ensureAudio(){ if(!audioCtx){ audioCtx = new (window.AudioContext||window.webkitAudioContext)(); panner = audioCtx.createStereoPanner ? audioCtx.createStereoPanner() : null; gain = audioCtx.createGain(); if(panner){ panner.connect(gain) } gain.connect(audioCtx.destination) } if(audioCtx.state==='suspended'){ audioCtx.resume() } }
|
||||
async function chooseAudioFolder(){ ensureAudio(); if(window.showDirectoryPicker){ try{ audioFolder = await window.showDirectoryPicker(); var st0 = BLS.loadState(); if(st0.audioEnabled && st0.audioName){ await loadAudioByName(st0.audioName); if(st0.running) playBuffer() } }catch(e){ return } } else { var input = document.createElement('input'); input.type='file'; input.webkitdirectory=true; input.multiple=true; input.addEventListener('change', function(){ var st0 = BLS.loadState(); var files = Array.from(input.files); var match = files.find(function(f){ return f.name === st0.audioName }); if(match){ var fr = new FileReader(); fr.onload = function(){ audioCtx.decodeAudioData(fr.result).then(function(buf){ audioBuffer = buf; if(st0.running && st0.audioEnabled) playBuffer() }) }; fr.readAsArrayBuffer(match) } }); input.click(); }
|
||||
}
|
||||
async function loadAudioByName(name){ if(!name) return; ensureAudio(); if(!audioFolder) return; try{ let handle = null; for await (const [n,h] of audioFolder.entries()){ if(h.kind==='file' && n===name){ handle=h; break } } if(!handle) return; var file = await handle.getFile(); var buf = await file.arrayBuffer(); audioBuffer = await audioCtx.decodeAudioData(buf); }catch(e){} }
|
||||
function playBuffer(){ if(!audioBuffer || !audioCtx) return; if(source){ try{ source.stop() }catch(e){} source.disconnect() } source = audioCtx.createBufferSource(); source.buffer = audioBuffer; source.loop = true; var nodeChainStart = source; if(panner){ nodeChainStart.connect(panner) } else { nodeChainStart.connect(gain) } source.start() }
|
||||
function stopAudio(){ if(source){ try{ source.stop() }catch(e){} source.disconnect(); source=null } }
|
||||
async function ensureFolder(){ if(!audioFolder){ try{ await chooseAudioFolder() }catch(e){} } }
|
||||
renderer.setOnUpdate(function(info){ if(panner){ var pan = (info.x / info.w) * 2 - 1; if(pan < -1) pan=-1; if(pan>1) pan=1; try{ panner.pan.value = pan }catch(e){} } })
|
||||
chan.on(async function(msg){ if(!msg || typeof msg!=='object') return; if(!msg.running){ stopAudio(); stopProgrammatic(); return } if(msg.audioEnabled){ if(msg.audioMode==='file'){ if(!audioFolder){ await ensureFolder() } if(msg.audioName){ await loadAudioByName(msg.audioName); playBuffer() } } else { stopAudio(); if(!audioUnlocked) bindUnlock(); startProgrammatic(msg) } } else { stopAudio(); stopProgrammatic() } })
|
||||
}
|
||||
function wait(){ if(window.BLS){ initPatient() } else { setTimeout(wait, 50) } }
|
||||
if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', wait) } else { wait() }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user