ZONOS3-Mini / assets /wave.js
fffiloni's picture
v0.3.4: add detailed rounded playback wave and refine audio player
589dcd3 verified
Raw History Blame Contribute Delete
4.89 kB
// 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;j<b;j++)sum+=(Number.isFinite(values[j])?values[j]:0)-mean;
this.bins[i]=sum/(b-a);
}
for(let i=0;i<256;i++)this.filtered[i]=(this.bins[Math.max(0,i-1)]+2*this.bins[i]+this.bins[Math.min(255,i+1)])/4;
// A rising zero crossing reduces whole-line phase flips between frames.
let start=0,best=0;
for(let i=1;i<64;i++){
const slope=this.filtered[i]-this.filtered[i-1];
if(this.filtered[i-1]<=0&&this.filtered[i]>0&&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<count;i++){
const x=20+dx*(i-1);
d+=` C${(x+dx/3).toFixed(2)} ${(ys[i-1]+tangent[i-1]/3).toFixed(2)} ${(x+2*dx/3).toFixed(2)} ${(ys[i]-tangent[i]/3).toFixed(2)} ${(x+dx).toFixed(2)} ${ys[i].toFixed(2)}`;
}
return d+' L1000 70';
}
}