← Natura·Docs/Modules/Source

creatures/voice-check.html

90 lines · in natura-src-1.0.0.zip as natura/creatures/voice-check.html · creature bench: the device-free audio bench for voice.js

<!doctype html><html><head><meta charset="utf-8"><title>voice-check -- the creature-voice audio bench</title>
<style>
  body{ background:#0d1013; color:#cdd6cf; font:13px/1.5 ui-monospace,Menlo,Consolas,monospace; margin:0; padding:16px; }
  h1{ font-size:15px; color:#8fbfa8; margin:0 0 4px; } .sub{ color:#6f857b; margin:0 0 14px; }
  .row{ display:flex; gap:18px; flex-wrap:wrap; align-items:flex-start; }
  canvas{ background:#05070a; border:1px solid #223; border-radius:4px; display:block; }
  .card{ } .lbl{ color:#7fa; font-size:12px; margin:6px 0 3px; }
  #metrics{ white-space:pre; background:#070b0e; border:1px solid #1c2a24; border-radius:4px; padding:10px 12px; min-width:320px; }
  .pass{ color:#8fe86a; } .warn{ color:#e8c05a; } .fail{ color:#e8735a; }
  a,button{ color:#8fbfa8; }
</style></head><body>
<h1>creature-voice audio bench</h1>
<p class="sub">renders a voice through an OfflineAudioContext (no speakers), draws its STFT spectrogram (log-freq) + waveform, and reports metrics. device-free, decisive. use ?voice=NAME (or ?scene=marshNight).</p>
<div class="row">
  <div class="card"><div class="lbl">SPECTROGRAM (log-freq 40Hz..20kHz, brighter=louder)</div><canvas id="spec" width="720" height="300"></canvas>
    <div class="lbl">WAVEFORM (L)</div><canvas id="wave" width="720" height="90"></canvas></div>
  <div id="metrics">rendering...</div>
</div>
<script src="voice.js"></script>
<script>
const SR=44100;
// ---- iterative radix-2 FFT (in-place, real+imag arrays) ----
function fft(re,im){ const n=re.length; for(let i=1,j=0;i<n;i++){ let bit=n>>1; for(;j&bit;bit>>=1) j^=bit; j^=bit; if(i<j){ [re[i],re[j]]=[re[j],re[i]]; [im[i],im[j]]=[im[j],im[i]]; } }
  for(let len=2;len<=n;len<<=1){ const ang=-2*Math.PI/len, wr=Math.cos(ang), wi=Math.sin(ang);
    for(let i=0;i<n;i+=len){ let cr=1,ci=0; for(let k=0;k<len/2;k++){ const ur=re[i+k], ui=im[i+k], vr=re[i+k+len/2]*cr-im[i+k+len/2]*ci, vi=re[i+k+len/2]*ci+im[i+k+len/2]*cr;
      re[i+k]=ur+vr; im[i+k]=ui+vi; re[i+k+len/2]=ur-vr; im[i+k+len/2]=ui-vi; const ncr=cr*wr-ci*wi; ci=cr*wi+ci*wr; cr=ncr; } } } }

function scheduleInto(ctx, dest, spec){ const dur=spec.dur;
  if(spec.scene){ const S=CreatureVoice.SCENES[spec.scene]; S.beds(ctx,dest,0,{dur}); for(let t=0.3;t<dur-0.4;t+=0.35) S.tick(ctx,dest,t); return; }
  const v=spec.voice, cont=(v==='cricket'||v==='reeds'), va=spec.variant;
  if(cont){ CreatureVoice.play(ctx,dest,v,0,{dur,variant:va}); }
  else if(spec.once){ CreatureVoice.play(ctx,dest,v,0.25,{variant:va, sex:spec.sex}); }   // one long call (a lion's bout)
  else { for(let t=0.25;t<dur-0.5;t+=0.9) CreatureVoice.play(ctx,dest,v,t,{variant:va}); }   // several one-shots spread out
}

async function run(){
  const p=new URLSearchParams(location.search);
  const spec = p.get('scene') ? {scene:p.get('scene'), dur:+(p.get('dur')||6)} : {voice:(p.get('voice')||'cricket'), dur:+(p.get('dur')||5), variant:p.get('variant')||undefined, once:!!p.get('once'), sex:p.get('sex')||undefined};
  const off=new OfflineAudioContext(2, SR*spec.dur, SR);
  scheduleInto(off, off.destination, spec);
  const buf=await off.startRendering();
  const L=buf.getChannelData(0), R=buf.getChannelData(1), N=L.length;
  // ---- metrics ----
  let sum=0,dsum=0,prev=0,lr=0,ll=0,rr=0,pk=0; for(let i=0;i<N;i++){ const s=(L[i]+R[i])*0.5; sum+=s*s; const df=s-prev; dsum+=df*df; prev=s; lr+=L[i]*R[i]; ll+=L[i]*L[i]; rr+=R[i]*R[i]; if(Math.abs(s)>pk)pk=Math.abs(s); }
  const rms=Math.sqrt(sum/N), hf=Math.sqrt(dsum/N), corr=lr/Math.sqrt(ll*rr+1e-12);
  const W=60, wl=Math.floor(N/W), wr2=[]; for(let w=0;w<W;w++){ let s2=0; for(let i=0;i<wl;i++){ const s=(L[w*wl+i]+R[w*wl+i])*0.5; s2+=s*s; } wr2.push(Math.sqrt(s2/wl)); }
  const wmax=Math.max(...wr2), wmin=Math.min(...wr2);
  // ---- STFT for the spectrogram + a high-freq energy fraction (>10kHz = harsh-harmonic tell) ----
  const FSZ=2048, HOP=1024, cols=[]; const han=new Float32Array(FSZ); for(let i=0;i<FSZ;i++) han[i]=0.5-0.5*Math.cos(2*Math.PI*i/FSZ);
  let hiE=0, totE=0;
  for(let s=0;s+FSZ<=N;s+=HOP){ const re=new Float64Array(FSZ), im=new Float64Array(FSZ);
    for(let i=0;i<FSZ;i++) re[i]=((L[s+i]+R[s+i])*0.5)*han[i];
    fft(re,im); const mag=new Float32Array(FSZ/2);
    for(let k=0;k<FSZ/2;k++){ const m=Math.sqrt(re[k]*re[k]+im[k]*im[k]); mag[k]=m; const f=k*SR/FSZ; totE+=m; if(f>10000) hiE+=m; }
    cols.push(mag);
  }
  const hiFrac=totE>0?hiE/totE:0;
  // ---- draw spectrogram (log-freq) ----
  const cv=document.getElementById('spec'), g=cv.getContext('2d'), CW=cv.width, CH=cv.height;
  g.fillStyle='#05070a'; g.fillRect(0,0,CW,CH);
  const fmin=40, fmax=20000, lgmin=Math.log(fmin), lgmax=Math.log(fmax);
  let gmax=1e-9; for(const c of cols) for(const m of c) if(m>gmax) gmax=m;
  for(let x=0;x<cols.length;x++){ const col=cols[x], px=Math.floor(x/cols.length*CW), pw=Math.ceil(CW/cols.length)+1;
    for(let y=0;y<CH;y++){ const f=Math.exp(lgmin+(lgmax-lgmin)*(1-y/CH)); const k=Math.round(f*FSZ/SR); if(k<1||k>=col.length) continue;
      const v=Math.min(1, Math.log(1+col[k]/gmax*40)/Math.log(41)); const hue=200-140*v;
      g.fillStyle='hsl('+hue+',80%,'+(6+v*54)+'%)'; g.fillRect(px,y,pw,1); } }
  // freq gridlines
  g.fillStyle='rgba(255,255,255,.25)'; g.font='10px monospace';
  [100,1000,5000,10000].forEach(f=>{ const y=CH*(1-(Math.log(f)-lgmin)/(lgmax-lgmin)); g.fillRect(0,y,CW,0.5); g.fillText((f>=1000?f/1000+'k':f+''),2,y-2); });
  // ---- waveform ----
  const wc=document.getElementById('wave'), wg=wc.getContext('2d'); wg.fillStyle='#05070a'; wg.fillRect(0,0,wc.width,wc.height); wg.strokeStyle='#5fb'; wg.beginPath();
  for(let x=0;x<wc.width;x++){ const i=Math.floor(x/wc.width*N); const y=wc.height/2*(1-L[i]*3); if(x===0)wg.moveTo(x,y); else wg.lineTo(x,y); } wg.stroke();
  // ---- verdict ----
  const isCont=spec.scene||spec.voice==='cricket'||spec.voice==='reeds';
  const checks=[
    ['audible + not clipping', rms>0.0003 && pk<0.99, 'rms='+rms.toFixed(4)+' peak='+pk.toFixed(3)],
    ['no harsh >10kHz harmonics (look: no dense columns to 20k)', hiFrac<0.12, 'hi-freq energy fraction='+hiFrac.toFixed(3)],
    [isCont?'a steady texture, not silent (drone = a solid TONAL line -- check the spectrogram)':'transient character (calls come + go)', isCont? (wmin>1e-6) : (wmax/(wmin+1e-6)>3), 'winRange='+(wmax/(wmin+1e-6)).toFixed(2)]
  ];
  if(spec.scene) checks.push(['stereo width present', Math.abs(corr)<0.999, 'L/R corr='+corr.toFixed(3)]);
  const allPass=checks.every(c=>c[1]);
  let html='VOICE: '+(spec.scene?('scene '+spec.scene):spec.voice)+'   ('+spec.dur+'s)\n\n';
  for(const c of checks) html+=(c[1]?'  PASS  ':'  WARN  ')+c[0]+'\n         '+c[2]+'\n';
  html+='\n'+(allPass?'  ---> CLEAN':'  ---> LOOK AT THE WARNINGS');
  const m=document.getElementById('metrics'); m.textContent=html;
  m.className = allPass?'pass':'warn';
  window.__check={ voice:spec.voice||spec.scene, rms:+rms.toFixed(4), peak:+pk.toFixed(3), hiFrac:+hiFrac.toFixed(3), winRange:+(wmax/(wmin+1e-6)).toFixed(2), stereoCorr:+corr.toFixed(3), pass:allPass };
}
run().catch(e=>{ document.getElementById('metrics').textContent='ERROR: '+e.message+'\n'+e.stack; window.__check={error:e.message}; });
</script></body></html>