// An audio-reactive illustration, not an oscilloscope or a PCM preview. // Energy comes from the playing audio; shape and continuous phase are visual. function voiceEnergy(values) { if (!values.length) return 0; let sum=0, squares=0; for (const value of values) { const v=Number.isFinite(value)?value:0; sum+=v; squares+=v*v; } const rms=Math.sqrt(Math.max(0,squares/values.length-(sum/values.length)**2)); // Soft display gain makes quiet speech visible; silence still stays flat. return Math.sqrt(1-Math.exp(-36*Math.max(0,rms-.0025))); } class WaveMotion { constructor() { this.level=0; this.phase=0; } step(target,seconds) { const dt=Math.min(.05,Math.max(0,seconds)); target=Math.min(1,Math.max(0,Number.isFinite(target)?target:0)); // A quick attack follows syllables; a longer release rounds their decay. // Time-based attack/release behaves alike at 30, 60 and 120 Hz. const tau=target > this.level ? .045 : .16; this.level+=(target-this.level)*(1-Math.exp(-dt/tau)); this.phase=(this.phase+dt*(4.2+3.2*this.level))%(Math.PI*20); if(target===0 && this.level<.001)this.level=0; return this; } } function wavePath(level,phase=0) { level=Math.min(1,Math.max(0,Number.isFinite(level)?level:0)); if(level<.001)return 'M0 70 L1000 70'; function y(t) { const edge=Math.min(1,t/.16,(1-t)/.16); const fade=edge*edge*edge*(edge*(edge*6-15)+10); const broad=.6+.4*(.5+.5*Math.sin(Math.PI*2*1.5*t-phase*.7)); const bend=1.1*Math.sin(Math.PI*2*.8*t-phase*.4); const carrier=(Math.sin(Math.PI*2*5.2*t+phase+bend)+.18*Math.sin(Math.PI*2*8.6*t-phase*.6+.7))/1.18; return 70-64*level*fade*broad*carrier; } const derivative=t=>t<=0||t>=1?0:(y(t+.0001)-y(t-.0001))/.0002; // Cubics follow the analytic curve, without saturation or flattened peaks. const count=128, width=960; let d='M0 70 L20 70'; for(let i=1;i<=count;i++) { const a=(i-1)/count,b=i/count,h=(b-a)/3; d+=` C${(20+width*(a+h)).toFixed(2)} ${(y(a)+derivative(a)*h).toFixed(2)} ${ (20+width*(b-h)).toFixed(2)} ${(y(b)-derivative(b)*h).toFixed(2)} ${ (20+width*b).toFixed(2)} ${y(b).toFixed(2)}`; } return d+' L1000 70'; } // Playback follows the actual PCM texture. The activity curve above stays intact. class PlaybackWave { constructor() { this.level=0; this.shape=new Float32Array(192); this.bins=new Float32Array(256); this.filtered=new Float32Array(256); } reset(){this.level=0;this.shape.fill(0);} step(values,seconds){ const dt=Math.min(.05,Math.max(0,seconds)); const target=values?.length?voiceEnergy(values):0; this.level+=(target-this.level)*(1-Math.exp(-dt/(target > this.level ? .025 : .12))); if(target===0&&this.level<.001)this.reset(); if(!values?.length||target===0)return this; let mean=0; for(const value of values)mean+=Number.isFinite(value)?value:0; mean/=values.length; // Average adjacent PCM samples, then remove only the finest display jitter. for(let i=0;i<256;i++){ const a=Math.floor(i*values.length/256),b=Math.max(a+1,Math.floor((i+1)*values.length/256)); let sum=0; for(let j=a;j0&&slope>best){best=slope;start=i;} } const magnitudes=Array.from(this.filtered.slice(start,start+192),Math.abs).sort((a,b)=>a-b); const scale=Math.max(.012,magnitudes[182]*1.25); const mix=1-Math.exp(-dt/.028); for(let i=0;i<192;i++){ const sample=Math.tanh(this.filtered[start+i]/scale); this.shape[i]+=(sample-this.shape[i])*mix; } return this; } path(){ if(this.level<.001)return 'M0 70 L1000 70'; const count=this.shape.length; const ys=Array.from(this.shape,(value,i)=>{ const t=i/(count-1),edge=Math.min(1,t/.12,(1-t)/.12); const fade=edge*edge*edge*(edge*(edge*6-15)+10); return 70-64*this.level*value*fade; }); // Monotone cubic interpolation keeps small ripples without pointed corners. const slope=ys.slice(1).map((y,i)=>y-ys[i]); const tangent=ys.map((_,i)=>i===0||i===count-1?0: slope[i-1]*slope[i]<=0?0:2*slope[i-1]*slope[i]/(slope[i-1]+slope[i])); const dx=960/(count-1);let d='M0 70 L20 70'; for(let i=1;i