diff --git a/js/admin.js b/js/admin.js index d000005..9c86c61 100644 --- a/js/admin.js +++ b/js/admin.js @@ -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'+n+'' } presetList.innerHTML = html } function applyState(next, broadcast){ var prevRunning = state && !!state.running; - state = BLS.mergeState(state, next||{}); - renderer.setState(state); + // Explicitly persist=true for Admin actions + 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(broadcast){ chan.send(state) } 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 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){ - state = BLS.mergeState(state, next||{}); - renderer.setState(state); + state = BLS.mergeState(state, next||{}, true); + renderer.setState(state, false); 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(glowEnable){ glowEnable.checked = !!state.glowEnabled } diff --git a/js/bls.js b/js/bls.js index fada194..3fcd64d 100644 --- a/js/bls.js +++ b/js/bls.js @@ -1,26 +1,340 @@ ;(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 = []; } 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;i0){ 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 } } - 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){} } + Channel.prototype.on = function(handler){ + if(!Channel._handlers[this.name]) Channel._handlers[this.name] = []; + Channel._handlers[this.name].push(handler); + }; + + // --- State Persistence --- + 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); + } } - 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 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; })(); diff --git a/js/patient.js b/js/patient.js new file mode 100644 index 0000000..6455b4f --- /dev/null +++ b/js/patient.js @@ -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(); + } + +})(); diff --git a/patient.html b/patient.html index 248ce2e..63a58d7 100644 --- a/patient.html +++ b/patient.html @@ -1,4 +1,4 @@ -BLS + @@ -23,77 +23,6 @@ BLS - +