Merge pull request #1 from type-two/code-cleanup-and-optimization-12114006608232278591
Refactor and optimize EMDR simulator for performance
This commit is contained in:
commit
e3396e6084
@ -131,8 +131,9 @@ function recordSession(){ var n = (patientName.value||'').trim()||'Unnamed'; var
|
|||||||
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="'+n+'">'+n+'</option>' } presetList.innerHTML = html }
|
||||||
function applyState(next, broadcast){
|
function applyState(next, broadcast){
|
||||||
var prevRunning = state && !!state.running;
|
var prevRunning = state && !!state.running;
|
||||||
state = BLS.mergeState(state, next||{});
|
// Explicitly persist=true for Admin actions
|
||||||
renderer.setState(state);
|
state = BLS.mergeState(state, next||{}, true);
|
||||||
|
renderer.setState(state, false); // No need to double-save in renderer
|
||||||
if(prevRunning !== !!state.running){ if(state.running){ renderer.start() } else { renderer.stop() } }
|
if(prevRunning !== !!state.running){ if(state.running){ renderer.start() } else { renderer.stop() } }
|
||||||
if(broadcast){ chan.send(state) }
|
if(broadcast){ chan.send(state) }
|
||||||
try{ if(window.Recorder && Recorder.running){ Recorder.recordState(BLS.loadState()) } }catch(_){ }
|
try{ if(window.Recorder && Recorder.running){ Recorder.recordState(BLS.loadState()) } }catch(_){ }
|
||||||
@ -147,8 +148,8 @@ function recordSession(){ var n = (patientName.value||'').trim()||'Unnamed'; var
|
|||||||
function labelForEasing(e){ return e==='ease'? 'Ease In-Out' : 'Linear' }
|
function labelForEasing(e){ return e==='ease'? 'Ease In-Out' : 'Linear' }
|
||||||
function labelForProgram(p){ var map={pink:'Pink Noise', hybrid:'Hybrid', tone:'Tone', click:'Click', ping:'Ping', woodblock:'Woodblock', bell:'Bell', bass:'Bass', kick:'Kick', snare:'Snare', hihat:'Hi-Hat', sweep:'Sweep', zap:'Zap', bubble:'Bubble'}; return map[p]||p }
|
function labelForProgram(p){ var map={pink:'Pink Noise', hybrid:'Hybrid', tone:'Tone', click:'Click', ping:'Ping', woodblock:'Woodblock', bell:'Bell', bass:'Bass', kick:'Kick', snare:'Snare', hihat:'Hi-Hat', sweep:'Sweep', zap:'Zap', bubble:'Bubble'}; return map[p]||p }
|
||||||
function applyStateLocal(next){
|
function applyStateLocal(next){
|
||||||
state = BLS.mergeState(state, next||{});
|
state = BLS.mergeState(state, next||{}, true);
|
||||||
renderer.setState(state);
|
renderer.setState(state, false);
|
||||||
if(speed){ speed.value = String(parseInt(state.speed||parseInt(speed.value,10)||250, 10)) }
|
if(speed){ speed.value = String(parseInt(state.speed||parseInt(speed.value,10)||250, 10)) }
|
||||||
if(size){ size.value = String(state.size||parseFloat(size.value)||80) }
|
if(size){ size.value = String(state.size||parseFloat(size.value)||80) }
|
||||||
if(glowEnable){ glowEnable.checked = !!state.glowEnabled }
|
if(glowEnable){ glowEnable.checked = !!state.glowEnabled }
|
||||||
|
|||||||
354
js/bls.js
354
js/bls.js
@ -1,26 +1,340 @@
|
|||||||
;(function(){
|
;(function(){
|
||||||
|
// --- Constants & Defaults ---
|
||||||
|
var defaultState = {
|
||||||
|
shape:'circle', shapeColor:'#00ff88', bgColor:'#000000', direction:'lr',
|
||||||
|
speed:250, speedMultiplier:1, size:28,
|
||||||
|
yPercent:50, yOscillateEnabled:false, yOscillateRate:0.5,
|
||||||
|
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, paused:false, pausedMs:0, pausedAt:null, durationSec:600, startedAt:null,
|
||||||
|
patientWidth:'full',
|
||||||
|
toneFreq:440, toneDurationMs:70, adsrAttackMs:8, adsrDecayMs:80, adsrSustainLevel:0.3, adsrReleaseMs:10
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Channel (Cross-Tab Communication) ---
|
||||||
function Channel(name){ this.name = name; this.handlers = []; }
|
function Channel(name){ this.name = name; this.handlers = []; }
|
||||||
Channel.prototype.on = function(handler){ this.handlers.push(handler) };
|
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){} };
|
Channel.prototype.send = function(payload){
|
||||||
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){} });
|
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._handlers = {};
|
||||||
Channel.prototype.on = function(handler){ if(!Channel._handlers[this.name]) Channel._handlers[this.name] = []; Channel._handlers[this.name].push(handler) };
|
Channel.prototype.on = function(handler){
|
||||||
var defaultState = { shape:'circle', shapeColor:'#00ff88', bgColor:'#000000', direction:'lr', speed:250, speedMultiplier:1, size:28, yPercent:50, yOscillateEnabled:false, yOscillateRate:0.5, 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, paused:false, pausedMs:0, pausedAt:null, durationSec:600, startedAt:null, patientWidth:'full', toneFreq:440, toneDurationMs:70, adsrAttackMs:8, adsrDecayMs:80, adsrSustainLevel:0.3, adsrReleaseMs:10 };
|
if(!Channel._handlers[this.name]) Channel._handlers[this.name] = [];
|
||||||
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) } }
|
Channel._handlers[this.name].push(handler);
|
||||||
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','speedMultiplier','size','yPercent','glowEnabled','glowIntensity','glowRate','rampEnabled','rampSeconds','easingMode','edgePauseMs','wiggleEnabled','wiggleAmplitude','audioMode','programPreset','syncPan','panRate','cueRate','volume','audioName','audioEnabled','patientWidth','toneFreq','toneDurationMs','adsrAttackMs','adsrDecayMs','adsrSustainLevel','adsrReleaseMs'] }
|
// --- State Persistence ---
|
||||||
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 loadState(){
|
||||||
function loadPresets(){ try{ var m = JSON.parse(localStorage.getItem('bls_presets')||'null'); return m&&typeof m==='object'? m : {} }catch(e){ return {} } }
|
try{
|
||||||
function savePresets(m){ try{ localStorage.setItem('bls_presets', JSON.stringify(m)) }catch(e){} }
|
var s = JSON.parse(localStorage.getItem('bls_state') || 'null');
|
||||||
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; var defPct = (state.direction==='lr'?50:state.direction==='ud'?5:state.direction==='diag_down'?5:95); var pct = (state.yPercent===undefined || state.yPercent===null ? defPct : state.yPercent); if(!pos){ if(state.direction==='lr'){pos={x:r+2,y:yFromPercent(h,r,pct)}} else if(state.direction==='ud'){pos={x:w/2,y:yFromPercent(h,r,pct)}} else {pos={x:r+2,y:yFromPercent(h,r,pct)}} } else { pos.y = yFromPercent(h, r, pct) } 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 n = next||{}; var prevDir = state.direction; var prevSize = state.size; var prevY = state.yPercent; var hadDir = Object.prototype.hasOwnProperty.call(n,'direction'); var hadSize = Object.prototype.hasOwnProperty.call(n,'size'); var hadY = Object.prototype.hasOwnProperty.call(n,'yPercent'); state=mergeState(state,n); if(hadDir && state.direction!==prevDir){ setDir() } if(hadSize && state.size!==prevSize){ ensurePos() } if(hadY){ var r=state.size; var defPct=(state.direction==='lr'?50:state.direction==='ud'?5:state.direction==='diag_down'?5:95); var pct=(state.yPercent===undefined||state.yPercent===null?defPct:state.yPercent); pos.y = yFromPercent(canvas.height, r, pct) } 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 } }
|
if(!s) return Object.assign({}, defaultState);
|
||||||
window.bls = { createChannel:function(name){ return new Channel(name) }, defaultState:defaultState, loadState:loadState, saveState:saveState, mergeState:mergeState, Renderer:Renderer,
|
return Object.assign({}, defaultState, s);
|
||||||
listPresets:function(){ var m=loadPresets(); return Object.keys(m).sort() },
|
} catch(e){
|
||||||
savePreset:function(name, st){ if(!name) return; var m=loadPresets(); m[name]=sanitizePreset(st||loadState()); savePresets(m) },
|
return Object.assign({}, defaultState);
|
||||||
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
|
|
||||||
|
function saveState(s){
|
||||||
|
try{ localStorage.setItem('bls_state', JSON.stringify(s)); } catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeState(prev, next, persist){
|
||||||
|
var s = Object.assign({}, prev, next);
|
||||||
|
if(persist !== false) saveState(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Presets ---
|
||||||
|
function presetKeys(){
|
||||||
|
return ['shape','shapeColor','bgColor','direction','speed','speedMultiplier','size','yPercent','glowEnabled','glowIntensity','glowRate','rampEnabled','rampSeconds','easingMode','edgePauseMs','wiggleEnabled','wiggleAmplitude','audioMode','programPreset','syncPan','panRate','cueRate','volume','audioName','audioEnabled','patientWidth','toneFreq','toneDurationMs','adsrAttackMs','adsrDecayMs','adsrSustainLevel','adsrReleaseMs'];
|
||||||
|
}
|
||||||
|
|
||||||
|
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){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Renderer ---
|
||||||
|
function Renderer(canvas){
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var state = loadState(); // Initial state loaded from storage
|
||||||
|
var pos = null;
|
||||||
|
var dir = {x:1, y:0};
|
||||||
|
var running = false;
|
||||||
|
var onUpdate = null;
|
||||||
|
var pauseMs = 0;
|
||||||
|
var wigglePhase = 0;
|
||||||
|
|
||||||
|
// Optimized: Reusable objects/vars to avoid GC
|
||||||
|
var _drawVars = {
|
||||||
|
x: 0, y: 0, r: 0, wx: 0, wy: 0,
|
||||||
|
doGlow: false, gi: 0, freq: 0, a: 0, hue: 0, t: 0, s: 0, h2: 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;
|
||||||
|
var defPct = (state.direction==='lr'?50 : state.direction==='ud'?5 : state.direction==='diag_down'?5 : 95);
|
||||||
|
var pct = (state.yPercent===undefined || state.yPercent===null ? defPct : state.yPercent);
|
||||||
|
|
||||||
|
if(!pos){
|
||||||
|
if(state.direction === 'lr'){ pos = {x: r+2, y: yFromPercent(h,r,pct)}; }
|
||||||
|
else if(state.direction === 'ud'){ pos = {x: w/2, y: yFromPercent(h,r,pct)}; }
|
||||||
|
else { pos = {x: r+2, y: yFromPercent(h,r,pct)}; }
|
||||||
|
} else {
|
||||||
|
pos.y = yFromPercent(h, r, pct);
|
||||||
|
}
|
||||||
|
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 v = _drawVars;
|
||||||
|
v.r = state.size;
|
||||||
|
v.x = pos.x;
|
||||||
|
v.y = pos.y;
|
||||||
|
v.doGlow = !!state.glowEnabled;
|
||||||
|
v.gi = state.glowIntensity || 1;
|
||||||
|
v.t = Date.now()/1000;
|
||||||
|
v.wx = 0; v.wy = 0;
|
||||||
|
|
||||||
|
if(state.wiggleEnabled && state.wiggleAmplitude > 0){
|
||||||
|
wigglePhase += 0.04 + state.speed/4000;
|
||||||
|
v.s = Math.sin(wigglePhase);
|
||||||
|
if(state.direction === 'lr'){ v.wy = v.s * state.wiggleAmplitude; }
|
||||||
|
else if(state.direction === 'ud'){ v.wx = v.s * state.wiggleAmplitude; }
|
||||||
|
else { v.wx = -dir.y * v.s * state.wiggleAmplitude; v.wy = dir.x * v.s * state.wiggleAmplitude; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if(v.doGlow){
|
||||||
|
v.freq = Math.max(0.05, state.glowRate || 0.5);
|
||||||
|
v.a = 0.5 + 0.5 * Math.sin(v.t * 2 * Math.PI * v.freq);
|
||||||
|
v.hue = (v.t * 60 * v.freq) % 360;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowBlur = v.r * v.gi * (0.7 + v.a);
|
||||||
|
ctx.shadowColor = 'hsla(' + v.hue + ',100%,60%,0.9)';
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = state.shapeColor;
|
||||||
|
|
||||||
|
if(state.shape === 'circle'){
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(v.x + v.wx, v.y + v.wy, v.r, 0, Math.PI*2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
} else if(state.shape === 'square'){
|
||||||
|
ctx.fillRect(v.x - v.r + v.wx, v.y - v.r + v.wy, v.r*2, v.r*2);
|
||||||
|
} else {
|
||||||
|
v.h2 = v.r * 1.6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(v.x + v.wx, v.y - v.h2/2 + v.wy);
|
||||||
|
ctx.lineTo(v.x - v.r + v.wx, v.y + v.h2/2 + v.wy);
|
||||||
|
ctx.lineTo(v.x + v.r + v.wx, v.y + v.h2/2 + v.wy);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(v.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, persist){
|
||||||
|
var n = next || {};
|
||||||
|
var prevDir = state.direction;
|
||||||
|
var prevSize = state.size;
|
||||||
|
var hadDir = Object.prototype.hasOwnProperty.call(n,'direction');
|
||||||
|
var hadSize = Object.prototype.hasOwnProperty.call(n,'size');
|
||||||
|
var hadY = Object.prototype.hasOwnProperty.call(n,'yPercent');
|
||||||
|
|
||||||
|
state = mergeState(state, n, persist); // Default persist=false for Renderer calls generally
|
||||||
|
|
||||||
|
if(hadDir && state.direction !== prevDir){ setDir(); }
|
||||||
|
if(hadSize && state.size !== prevSize){ ensurePos(); }
|
||||||
|
|
||||||
|
if(hadY){
|
||||||
|
var r = state.size;
|
||||||
|
var defPct = (state.direction==='lr'?50 : state.direction==='ud'?5 : state.direction==='diag_down'?5 : 95);
|
||||||
|
var pct = (state.yPercent===undefined || state.yPercent===null ? defPct : state.yPercent);
|
||||||
|
pos.y = yFromPercent(canvas.height, r, pct);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Export ---
|
||||||
|
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;
|
||||||
})();
|
})();
|
||||||
|
|||||||
450
js/patient.js
Normal file
450
js/patient.js
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
;(function(){
|
||||||
|
// --- Constants & Globals ---
|
||||||
|
var canvas, renderer, chan, ws;
|
||||||
|
var widthMode = 'full';
|
||||||
|
var running = false;
|
||||||
|
var audioFolder = null;
|
||||||
|
var audioBuffer = null;
|
||||||
|
var audioUnlocked = false;
|
||||||
|
var pendingProgramState = null;
|
||||||
|
|
||||||
|
// Audio Context & Nodes
|
||||||
|
var audioCtx = null, panner = null, gain = null, source = null;
|
||||||
|
|
||||||
|
// --- Initialization ---
|
||||||
|
function initPatient(){
|
||||||
|
canvas = document.getElementById('canvas');
|
||||||
|
|
||||||
|
// Load initial width preference
|
||||||
|
var initialState = BLS.loadState();
|
||||||
|
widthMode = initialState.patientWidth || 'full';
|
||||||
|
|
||||||
|
// Setup Resize Handler
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
size();
|
||||||
|
sendViewport(); // Initial send
|
||||||
|
|
||||||
|
// Initialize Renderer
|
||||||
|
// Note: We will modify BLS.Renderer to NOT save state by default,
|
||||||
|
// or we will just use it as is and fix the saving issue in bls.js later.
|
||||||
|
renderer = BLS.Renderer(canvas);
|
||||||
|
|
||||||
|
// Setup Local Channel (for same-browser tab communication)
|
||||||
|
chan = BLS.createChannel('bls-session');
|
||||||
|
chan.on(handleStateUpdate);
|
||||||
|
|
||||||
|
// Setup Network
|
||||||
|
setupNetwork();
|
||||||
|
|
||||||
|
// Initial State Load
|
||||||
|
renderer.setState(initialState);
|
||||||
|
var st = renderer.getState();
|
||||||
|
if(st.running){
|
||||||
|
renderer.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio Unlock Binding
|
||||||
|
bindUnlock();
|
||||||
|
|
||||||
|
// Sync Panner with Renderer
|
||||||
|
renderer.setOnUpdate(onRenderUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Viewport Management ---
|
||||||
|
function size(){
|
||||||
|
var w = window.innerWidth;
|
||||||
|
var h = window.innerHeight;
|
||||||
|
var fac = (widthMode === 'reduced' ? 0.7 : 1);
|
||||||
|
canvas.width = Math.floor(w * fac);
|
||||||
|
canvas.height = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendViewportTimeout = null;
|
||||||
|
function sendViewport(){
|
||||||
|
if(sendViewportTimeout) clearTimeout(sendViewportTimeout);
|
||||||
|
sendViewportTimeout = setTimeout(doSendViewport, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSendViewport(){
|
||||||
|
try {
|
||||||
|
var pv = {
|
||||||
|
winW: window.innerWidth,
|
||||||
|
winH: window.innerHeight,
|
||||||
|
canvasW: canvas.width,
|
||||||
|
canvasH: canvas.height
|
||||||
|
};
|
||||||
|
|
||||||
|
if(ws && ws.readyState === 1){
|
||||||
|
ws.send(JSON.stringify({type:'patientViewport', payload: pv}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(window.NetRTC && NetRTC.send){
|
||||||
|
NetRTC.send({type:'patientViewport', payload: pv});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if(window.opener){
|
||||||
|
window.opener.postMessage({type:'patientViewport', payload: pv}, '*');
|
||||||
|
}
|
||||||
|
} catch(_){}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if(window.BLS){
|
||||||
|
BLS.createChannel('bls-session').send({type:'patientViewport', payload: pv});
|
||||||
|
}
|
||||||
|
} catch(_){}
|
||||||
|
} catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize(){
|
||||||
|
size();
|
||||||
|
sendViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State Management ---
|
||||||
|
function handleStateUpdate(msg){
|
||||||
|
if(!msg || typeof msg !== 'object') return;
|
||||||
|
|
||||||
|
// Deep copy to avoid reference issues
|
||||||
|
var apply = Object.assign({}, msg);
|
||||||
|
|
||||||
|
// Fixups for specific flags
|
||||||
|
if(apply.rampEnabled === false) apply.easingMode = 'linear';
|
||||||
|
if(apply.glowEnabled === false) apply.glowIntensity = 0;
|
||||||
|
|
||||||
|
// Update Renderer State
|
||||||
|
// Important: The patient view should not persist this state to localStorage
|
||||||
|
// to avoid performance hits. We disable persistence here.
|
||||||
|
renderer.setState(apply, false);
|
||||||
|
|
||||||
|
// Check for Width Mode change
|
||||||
|
var nextMode = apply.patientWidth || widthMode;
|
||||||
|
if(nextMode !== widthMode){
|
||||||
|
widthMode = nextMode;
|
||||||
|
size();
|
||||||
|
}
|
||||||
|
|
||||||
|
var st = renderer.getState();
|
||||||
|
|
||||||
|
// Handle Running State
|
||||||
|
if(!st.running || st.paused){
|
||||||
|
stopAudio();
|
||||||
|
stopProgrammatic();
|
||||||
|
renderer.stop();
|
||||||
|
running = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Audio
|
||||||
|
if(st.audioEnabled){
|
||||||
|
if(st.audioMode === 'file'){
|
||||||
|
handleFileAudio(st);
|
||||||
|
} else {
|
||||||
|
stopAudio();
|
||||||
|
if(!audioUnlocked) bindUnlock();
|
||||||
|
startProgrammatic(st);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopAudio();
|
||||||
|
stopProgrammatic();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!running){
|
||||||
|
renderer.start();
|
||||||
|
running = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Network ---
|
||||||
|
function setupNetwork(){
|
||||||
|
var qs = new URLSearchParams(location.search);
|
||||||
|
var hashSid = (location.hash || '').replace('#', '');
|
||||||
|
var wsSid = qs.get('sid') || hashSid || 'default';
|
||||||
|
var wsUrl = qs.get('url') || ('ws://' + (location.hostname || 'localhost') + ':8787');
|
||||||
|
var rtcMode = !!qs.get('p2p');
|
||||||
|
|
||||||
|
if(!rtcMode && wsUrl){
|
||||||
|
connectWebSocket(wsUrl, wsSid);
|
||||||
|
} else if(rtcMode){
|
||||||
|
setupWebRTC();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWebSocket(url, sid){
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
} catch(e){ return; }
|
||||||
|
|
||||||
|
ws.onopen = function(){
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({type:'join', sessionId: sid, role:'patient'}));
|
||||||
|
doSendViewport();
|
||||||
|
} catch(e){}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function(ev){
|
||||||
|
try {
|
||||||
|
var msg = JSON.parse(ev.data);
|
||||||
|
if(msg && msg.type === 'state'){
|
||||||
|
handleStateUpdate(msg.payload || {});
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function(){ ws = null; };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWebRTC(){
|
||||||
|
var offerStr = prompt('Paste Offer from Admin');
|
||||||
|
if(offerStr){
|
||||||
|
NetRTC.startAnswerer(offerStr).then(function(answer){
|
||||||
|
alert('Show this Answer to Admin to complete connect');
|
||||||
|
console.log(answer);
|
||||||
|
try {
|
||||||
|
if(navigator.clipboard) navigator.clipboard.writeText(answer);
|
||||||
|
} catch(_){}
|
||||||
|
});
|
||||||
|
|
||||||
|
NetRTC.onMessage(function(msg){
|
||||||
|
if(msg && msg.type === 'state'){
|
||||||
|
handleStateUpdate(msg.payload || {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try { doSendViewport(); } catch(e){}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audio Logic ---
|
||||||
|
async function handleFileAudio(st){
|
||||||
|
if(!audioFolder){
|
||||||
|
await ensureFolder();
|
||||||
|
}
|
||||||
|
if(st.audioName){
|
||||||
|
await loadAudioByName(st.audioName);
|
||||||
|
playBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUnlock(){
|
||||||
|
var ov = document.getElementById('overlay');
|
||||||
|
if(ov) ov.style.display = 'flex';
|
||||||
|
|
||||||
|
var events = ['pointerdown','keydown','touchstart','mousemove'];
|
||||||
|
|
||||||
|
function unlockOnce(){
|
||||||
|
try {
|
||||||
|
AudioEngine.ensureCtx();
|
||||||
|
audioUnlocked = true;
|
||||||
|
AudioEngine.playPing(0);
|
||||||
|
} catch(e){}
|
||||||
|
|
||||||
|
if(ov) ov.style.display = 'none';
|
||||||
|
|
||||||
|
if(pendingProgramState){
|
||||||
|
var st = pendingProgramState;
|
||||||
|
pendingProgramState = null;
|
||||||
|
startProgrammatic(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.forEach(function(ev){
|
||||||
|
try{ window.removeEventListener(ev, unlockOnce); } catch(_){}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
events.forEach(function(ev){
|
||||||
|
window.addEventListener(ev, unlockOnce, { once:true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var progRunning = false;
|
||||||
|
|
||||||
|
function stopProgrammatic(){
|
||||||
|
AudioEngine.stopNoise();
|
||||||
|
progRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProgrammatic(st){
|
||||||
|
if(!audioUnlocked){
|
||||||
|
pendingProgramState = st;
|
||||||
|
bindUnlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioEngine.setVolume(st.volume || 0.6);
|
||||||
|
|
||||||
|
// Clear previous update handlers related to audio
|
||||||
|
renderer.setOnUpdate(onRenderUpdate);
|
||||||
|
|
||||||
|
if(st.programPreset === 'pink'){
|
||||||
|
if(st.syncPan){
|
||||||
|
// We handle panning in onRenderUpdate
|
||||||
|
AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate || 0.5));
|
||||||
|
} 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 {
|
||||||
|
// Discrete sounds on edges
|
||||||
|
renderer.setOnUpdate(function(info){
|
||||||
|
onRenderUpdate(info); // Call the base update for visual/panner sync
|
||||||
|
|
||||||
|
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 === 'tone'){
|
||||||
|
AudioEngine.playTone({
|
||||||
|
freq: st.toneFreq || 440,
|
||||||
|
durationMs: st.toneDurationMs || 70,
|
||||||
|
pan: pan,
|
||||||
|
adsr: {
|
||||||
|
attackMs: st.adsrAttackMs || 8,
|
||||||
|
decayMs: st.adsrDecayMs || 80,
|
||||||
|
sustain: st.adsrSustainLevel || 0.3,
|
||||||
|
releaseMs: st.adsrReleaseMs || 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else 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 {
|
||||||
|
// Fallback for non-Chrome browsers
|
||||||
|
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){}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRenderUpdate(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){}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bootstrap ---
|
||||||
|
function wait(){
|
||||||
|
if(window.BLS){
|
||||||
|
initPatient();
|
||||||
|
} else {
|
||||||
|
setTimeout(wait, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(document.readyState === 'loading'){
|
||||||
|
document.addEventListener('DOMContentLoaded', wait);
|
||||||
|
} else {
|
||||||
|
wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
75
patient.html
75
patient.html
@ -1,4 +1,4 @@
|
|||||||
BLS<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
@ -23,77 +23,6 @@ BLS<!doctype html>
|
|||||||
<script src="js/bls.js"></script>
|
<script src="js/bls.js"></script>
|
||||||
<script src="js/audio.js"></script>
|
<script src="js/audio.js"></script>
|
||||||
<script src="js/webrtc.js"></script>
|
<script src="js/webrtc.js"></script>
|
||||||
<script>
|
<script src="js/patient.js"></script>
|
||||||
function initPatient(){
|
|
||||||
var canvas = document.getElementById('canvas')
|
|
||||||
var widthMode = (BLS.loadState().patientWidth||'full');
|
|
||||||
function size(){ var w = window.innerWidth, h = window.innerHeight; var fac = (widthMode==='reduced'? 0.7 : 1); canvas.width = Math.floor(w*fac); canvas.height = h }
|
|
||||||
function sendViewport(){ try{ var pv = { winW: window.innerWidth, winH: window.innerHeight, canvasW: canvas.width, canvasH: canvas.height }; if(ws && ws.readyState===1){ ws.send(JSON.stringify({type:'patientViewport', payload: pv})) } if(window.NetRTC && NetRTC.send){ NetRTC.send({type:'patientViewport', payload: pv}) } try{ if(window.opener){ window.opener.postMessage({type:'patientViewport', payload: pv}, '*') } }catch(_){ } try{ if(window.BLS){ BLS.createChannel('bls-session').send({type:'patientViewport', payload: pv}) } }catch(_){ } }catch(e){} }
|
|
||||||
size(); sendViewport(); window.addEventListener('resize', function(){ size(); sendViewport() })
|
|
||||||
var renderer = BLS.Renderer(canvas)
|
|
||||||
var chan = BLS.createChannel('bls-session')
|
|
||||||
var qs = new URLSearchParams(location.search)
|
|
||||||
var hashSid = (location.hash||'').replace('#','')
|
|
||||||
var wsSid = qs.get('sid') || hashSid || 'default'
|
|
||||||
var wsUrl = qs.get('url') || ('ws://' + (location.hostname||'localhost') + ':8787')
|
|
||||||
var ws = null
|
|
||||||
var rtcMode = !!qs.get('p2p')
|
|
||||||
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() } })
|
|
||||||
var running = false;
|
|
||||||
function wsConnect(){ if(!wsUrl) return; try{ ws = new WebSocket(wsUrl) }catch(e){ return } ws.onopen = function(){ try{ ws.send(JSON.stringify({type:'join', sessionId: wsSid, role:'patient'})) }catch(e){} };
|
|
||||||
ws.onmessage = function(ev){
|
|
||||||
try{
|
|
||||||
var msg = JSON.parse(ev.data);
|
|
||||||
if(msg && msg.type==='state'){
|
|
||||||
var apply = Object.assign({}, msg.payload||{});
|
|
||||||
if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' }
|
|
||||||
if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 }
|
|
||||||
renderer.setState(apply);
|
|
||||||
var nextMode = apply.patientWidth || widthMode;
|
|
||||||
if(nextMode !== widthMode){ widthMode = nextMode; size() }
|
|
||||||
var st = renderer.getState();
|
|
||||||
if(!st.running || st.paused){ stopAudio(); stopProgrammatic(); renderer.stop(); running=false; return }
|
|
||||||
if(st.audioEnabled){
|
|
||||||
if(st.audioMode==='file'){
|
|
||||||
(async function(){ if(!audioFolder){ await ensureFolder() } if(st.audioName){ await loadAudioByName(st.audioName); playBuffer() } })()
|
|
||||||
} else {
|
|
||||||
stopAudio(); if(!audioUnlocked) bindUnlock(); startProgrammatic(st)
|
|
||||||
}
|
|
||||||
} else { stopAudio(); stopProgrammatic() }
|
|
||||||
if(!running){ renderer.start(); running=true }
|
|
||||||
}
|
|
||||||
}catch(e){}
|
|
||||||
};
|
|
||||||
ws.onclose = function(){ ws = null }
|
|
||||||
ws.onopen = function(){ try{ sendViewport() }catch(e){} }
|
|
||||||
}
|
|
||||||
if(!rtcMode && wsUrl){ wsConnect() }
|
|
||||||
if(rtcMode){
|
|
||||||
var offerStr = prompt('Paste Offer from Admin');
|
|
||||||
if(offerStr){ NetRTC.startAnswerer(offerStr).then(function(answer){ alert('Show this Answer to Admin to complete connect'); console.log(answer); try{ navigator.clipboard && navigator.clipboard.writeText(answer) }catch(_){ } }); NetRTC.onMessage(function(msg){ if(msg && msg.type==='state'){ var apply = Object.assign({}, msg.payload||{}); if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' } if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 } renderer.setState(apply); var st = renderer.getState(); if(!st.running || st.paused){ stopAudio(); stopProgrammatic(); renderer.stop(); return } if(st.audioEnabled){ if(st.audioMode==='file'){ (async function(){ if(!audioFolder){ await ensureFolder() } if(st.audioName){ await loadAudioByName(st.audioName); playBuffer() } })() } else { stopAudio(); if(!audioUnlocked) bindUnlock(); startProgrammatic(st) } } else { stopAudio(); stopProgrammatic() } renderer.start() } }); try{ sendViewport() }catch(e){} }
|
|
||||||
}
|
|
||||||
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(){ var ov = document.getElementById('overlay'); if(ov){ ov.style.display='flex' } ['pointerdown','keydown','touchstart','mousemove'].forEach(function(ev){ window.addEventListener(ev, function once(){ try{ AudioEngine.ensureCtx(); audioUnlocked = true; AudioEngine.playPing(0) }catch(e){} if(ov){ ov.style.display='none' } 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==='tone'){ AudioEngine.playTone({ freq: st.toneFreq||440, durationMs: st.toneDurationMs||70, pan: pan, adsr:{ attackMs: st.adsrAttackMs||8, decayMs: st.adsrDecayMs||80, sustain: st.adsrSustainLevel||0.3, releaseMs: st.adsrReleaseMs||10 } }) } else 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 || msg.paused){ stopAudio(); stopProgrammatic(); renderer.stop(); 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user