diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2537419..b70f855 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: - run: sudo apt-get install -y ffmpeg - run: node scripts/fixtures.mjs - run: npm test + - run: node --expose-gc scripts/benchmark.mjs - run: npm run build - uses: actions/upload-artifact@v4 with: @@ -36,7 +37,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version-file: .nvmrc - - run: npm install --no-save --package-lock=false playwright@1.63.0 + - run: npm install --no-save --package-lock=false playwright@1.63.0 axe-core@4.10.3 - run: npx playwright install --with-deps chromium firefox webkit - name: Start virtual audio device run: | @@ -47,3 +48,15 @@ jobs: - run: sudo apt-get install -y ffmpeg - run: node scripts/fixtures.mjs - run: npm run test:browser + safari: + runs-on: macos-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - run: brew install ffmpeg + - run: node scripts/fixtures.mjs + - run: sudo safaridriver --enable + - run: python3 tests/safari.py diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs new file mode 100644 index 0000000..0c28412 --- /dev/null +++ b/scripts/benchmark.mjs @@ -0,0 +1,22 @@ +import { cpus, totalmem, platform, release } from 'node:os'; +import { performance } from 'node:perf_hooks'; +import { project, voice, preset } from '../src/model.js'; +import { analyze, resolveStrokes } from '../src/signal.js'; +import { render } from '../src/render.js'; +const report={node:process.version,platform:platform(),os:release(),cpu:cpus()[0]?.model,cores:cpus().length,ramMiB:Math.round(totalmem()/1024**2),runs:[]}; +const p=project();p.duration=10;p.mode='envelope'; +p.voices=Array.from({length:4},(_,i)=>{const v=voice(i);v.hz=220+i*43;v.pitch=[[0,v.hz],[.4,v.hz*2],[1,v.hz*.7]];v.waveform.strokes=preset('saw');v.waveform.resolution=16384;v.harmonics=1024;v.gain=[[0,0],[.01,.7],[.8,.5],[1,0]];return v;}); +p.selectedId=p.modulation.oscillatorId=p.voices[0].id;p.assets=['clip']; +p.modulation={...p.modulation,enabled:true,mode:'pitch',target:'mix',sourceId:'clip',semitones:24,sourceLoop:true,carrierLoop:true}; +const assets={clip:{sampleRate:48000,samples:Float32Array.from({length:480000},(_,i)=>.8*Math.sin(2*Math.PI*7*i/48000))}}; +const analysisTimes=[]; +for(let i=0;i<20;i++){const t=performance.now();analyze(resolveStrokes(p.voices[0].waveform.strokes,16384),220,1024);analysisTimes.push(performance.now()-t);} +report.analysisP95Ms=analysisTimes.sort((a,b)=>a-b)[18]; +for(let i=0;i<10;i++){ + global.gc?.();const before=process.memoryUsage(),start=performance.now(),result=await render(p,assets),ms=performance.now()-start,after=process.memoryUsage(); + report.runs.push({ms,frames:result.samples.length,peak:result.peak,arrayBuffersMiB:after.arrayBuffers/1024**2,heapMiB:after.heapUsed/1024**2,rssMiB:after.rss/1024**2,arrayBufferIncreaseMiB:(after.arrayBuffers-before.arrayBuffers)/1024**2}); +} +report.renderWorstMs=Math.max(...report.runs.map(r=>r.ms));report.renderP95Ms=report.runs.map(r=>r.ms).sort((a,b)=>a-b)[Math.ceil(report.runs.length*.95)-1];report.proposedTargets={analysisP95Ms:150,renderMs:2000,appOwnedBufferMiB:128}; +console.log('BENCHMARK:'+JSON.stringify(report)); +if(report.runs.some(r=>r.frames!==480000||r.peak>.950001||r.arrayBuffersMiB>128))throw new Error('Resource or output invariant failed'); +// Performance is reported explicitly; any missed release target must be addressed or documented. diff --git a/src/app.js b/src/app.js index bea392b..8c46cf2 100644 --- a/src/app.js +++ b/src/app.js @@ -1,6 +1,6 @@ import { project, voice, id, clone, COLORS, duration, preset } from './model.js'; import { History } from './history.js'; -import { resolveStrokes, analyze, sample, envelope, spectrum } from './signal.js'; +import { resolveStrokes, analyze, reconstruct, sample, envelope, spectrum } from './signal.js'; import { DrawingCanvas, editEnvelope, drawSignal } from './canvas.js'; import { Renderer, Transport } from './audio.js'; const $=selector=>document.querySelector(selector); @@ -19,10 +19,11 @@ export function element(tag,attrs={},...children){ } const history=new History(project()),renderer=new Renderer(),changes=new Set(),cache=new Map(); let playing=false,playingTag='editor',intent=0,lastRender=null,bypass=false,renderQueue=Promise.resolve(); -const view={tool:'pencil',zoom:1,pan:0,snap:false,only:false,hidden:new Set()}; +const view={modelOverlay:false,tool:'pencil',zoom:1,pan:0,snap:false,only:false,hidden:new Set()}; const transport=new Transport((state,tag)=>{playing=state==='playing';playingTag=tag||playingTag;$('#play').textContent=playing?'↻ Restart':'▶ Play';$('#play').setAttribute('aria-label',playing?'Restart playback':'Play sound');$('#transport-state').textContent=playing?'Playing '+(tag==='editor'?'current sound':tag):'Stopped';}); export const app={ - get project(){return history.current;},get lastRender(){return lastRender;},assets:{}, + get project(){return history.current;}, + retainedAssetIds(){return [...new Set([history.current,...history.past,...history.future].flatMap(p=>p.assets))];},get lastRender(){return lastRender;},get playbackRevision(){return intent;},assets:{}, element,transport,renderer, status(message,error=false){$('#status').textContent=message;$('#status').classList.toggle('error',error);}, onChange(fn){changes.add(fn);return()=>changes.delete(fn);}, @@ -60,12 +61,13 @@ export const app={ } }; -$('#app').innerHTML="
◒ SIGINTSignal sketchpad
Stopped
\n
\n

DRAW → UNDERSTAND → HEAR

Your next sound starts with a line.

\n
\n\n
\n
\n
\n
Waveform · signed amplitude
\n
+1
0
−1
\n
Time →
\n\n
1×
\n
\n
Edit exact points without drawing

Waveform: −1…1. Pitch: 20…20,000 Hz. Volume: 0…1. Undo restores every edit.

\n
\n\n
\n
Draw on the canvas, then press Play. Sound stays on this device.
\n
\n
"; +$('#app').innerHTML="
◒ SIGINTSignal sketchpad
Stopped
\n
\n

DRAW → UNDERSTAND → HEAR

Your next sound starts with a line.

\n
\n\n
\n
\n
\n
Waveform · signed amplitude
\n
+1
0
−1
\n
Time →
\n\n
1×
\n
\n
Edit exact points without drawing

Waveform: −1…1. Pitch: 20…20,000 Hz. Volume: 0…1. Undo restores every edit.

\n
\n\n
\n
Draw on the canvas, then press Play. Sound stays on this device.
\n
\n
"; function selected(){return app.project.voices.find(v=>v.id===app.project.selectedId);} function data(v){ const old=cache.get(v.id);if(old?.voice===v)return old; const samples=resolveStrokes(v.waveform.strokes,v.waveform.resolution,v.waveform.smooth); - const next={voice:v,samples,analysis:analyze(samples,v.hz,v.harmonics)};cache.set(v.id,next);return next; + const analysis=analyze(samples,v.hz,v.harmonics),model=v.source==='sine'?Float64Array.from({length:samples.length},(_,i)=>analysis.sine.offset+analysis.sine.amplitude*Math.sin(2*Math.PI*analysis.sine.harmonic*i/samples.length+analysis.sine.phase)):reconstruct(analysis.coefficients,samples.length,v.harmonics); + const next={voice:v,samples,analysis,model};cache.set(v.id,next);return next; } function changed(){ const wasPlaying=playing&&playingTag==='editor';intent++;renderer.cancel(); @@ -124,6 +126,7 @@ function curveSettings(lane){ }else curves=p.voices.filter(o=>(!view.only||o.id===v.id)&&!view.hidden.has(o.id)).map(o=>({ color:o.color,selected:o.id===v.id,value:u=>actual==='waveform'?sample(data(o).samples,u*o.hz/v.hz):envelope(o[actual],u,actual==='pitch',actual==='pitch'?o.hz:1) })); + if(actual==='waveform'&&view.modelOverlay)curves.push({color:v.color,strokeColor:'#e8edf5',pattern:[3,5],selected:false,value:u=>sample(data(v).model,u)}); return {...view,lane:actual,color:v.color,curves}; } function stroke(points,s){ @@ -197,6 +200,7 @@ $('#clear').onclick=()=>app.edit(p=>{if(p.mode==='timeline')p.timeline.strokes=[ $('#only').onchange=e=>{view.only=e.target.checked;draw();};$('#snap').onchange=e=>{view.snap=e.target.checked;}; $('#zoom').oninput=e=>{view.zoom=Number(e.target.value);view.pan=Math.min(view.pan,1-1/view.zoom);$('#pan').max=1-1/view.zoom;$('#pan').value=view.pan;$('#zoom-value').textContent=view.zoom+'×';refresh();}; $('#pan').oninput=e=>{view.pan=Number(e.target.value);refresh();}; +$('#model-overlay').onchange=e=>{view.modelOverlay=e.target.checked;draw();}; $('#bypass').onchange=e=>{bypass=e.target.checked;if(playing)app.play();}; $('#point-lane').onchange=e=>{$('#point-value').value=e.target.value==='pitch'?440:e.target.value==='gain'?1:0;}; $('#point-form').onsubmit=e=>{ diff --git a/src/canvas.js b/src/canvas.js index 8b1fde5..cc0630f 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -50,7 +50,7 @@ export class DrawingCanvas { for(let i=0;i<=4;i++){ctx.beginPath();ctx.moveTo(0,h*i/4);ctx.lineTo(w,h*i/4);ctx.stroke();} if(s.lane==='waveform'||s.lane==='timeline'){ctx.strokeStyle='#586882';ctx.beginPath();ctx.moveTo(0,h/2);ctx.lineTo(w,h/2);ctx.stroke();} for(const curve of s.curves){ - ctx.beginPath();ctx.strokeStyle=COLORS[curve.color]||'#b4c8df';ctx.lineWidth=curve.selected?2.5:1.5;ctx.globalAlpha=curve.selected?1:.55;ctx.setLineDash(curve.selected?[]:[5+curve.color*2,4]); + ctx.beginPath();ctx.strokeStyle=curve.strokeColor||COLORS[curve.color]||'#b4c8df';ctx.lineWidth=curve.selected?2.5:1.5;ctx.globalAlpha=curve.selected?1:.55;ctx.setLineDash(curve.pattern||(curve.selected?[]:[5+curve.color*2,4])); for(let x=0;x<=w;x++){const u=s.pan+x/w/s.zoom,v=curve.value(u),y=mapY(v);if(!x)ctx.moveTo(x,y);else ctx.lineTo(x,y);} ctx.stroke(); } diff --git a/src/format.js b/src/format.js index 943fe50..762020d 100644 --- a/src/format.js +++ b/src/format.js @@ -28,17 +28,17 @@ export function unzip(bytes){ const v=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength),end=bytes.length-22; if(v.getUint32(end,true)!==0x06054b50||v.getUint16(end+20,true)!==0)throw new Error('Unsupported bundle directory'); const count=v.getUint16(end+10,true),central=v.getUint32(end+16,true),centralSize=v.getUint32(end+12,true); - if(count>32||v.getUint16(end+8,true)!==count||central+centralSize!==end)throw new Error('Invalid bundle entry limit or directory'); + if(v.getUint16(end+4,true)!==0||v.getUint16(end+6,true)!==0||count>32||v.getUint16(end+8,true)!==count||central+centralSize!==end)throw new Error('Invalid bundle entry limit or directory'); const entries=Object.create(null);let pos=central,total=0; for(let i=0;iend||v.getUint32(pos,true)!==0x02014b50)throw new Error('Invalid bundle entry'); const flags=v.getUint16(pos+8,true),method=v.getUint16(pos+10,true),crc=v.getUint32(pos+16,true),size=v.getUint32(pos+20,true),expanded=v.getUint32(pos+24,true),length=v.getUint16(pos+28,true),extra=v.getUint16(pos+30,true),comment=v.getUint16(pos+32,true),local=v.getUint32(pos+42,true); - if(method!==0||flags!==0x800||size!==expanded)throw new Error('Use a SIGINT bundle with stored PCM entries'); + if(v.getUint16(pos+34,true)!==0||method!==0||flags!==0x800||size!==expanded)throw new Error('Use a SIGINT bundle with stored PCM entries'); if(pos+46+length+extra+comment>end||local+30>=central)throw new Error('Truncated bundle'); const name=decoder.decode(bytes.subarray(pos+46,pos+46+length)); if(!/^(project\.json|manifest\.json|audio\/[a-zA-Z0-9_-]+\.f32)$/.test(name)||Object.hasOwn(entries,name))throw new Error('Unsafe or duplicate bundle path'); total+=expanded;if(total>64*1024**2)throw new Error('Bundle exceeds extracted byte limit'); - if(v.getUint32(local,true)!==0x04034b50||v.getUint16(local+8,true)!==0||v.getUint32(local+18,true)!==size||v.getUint32(local+22,true)!==size)throw new Error('Mismatched bundle header'); + if(v.getUint16(local+6,true)!==flags||v.getUint32(local,true)!==0x04034b50||v.getUint16(local+8,true)!==0||v.getUint32(local+18,true)!==size||v.getUint32(local+22,true)!==size)throw new Error('Mismatched bundle header'); const n=v.getUint16(local+26,true),x=v.getUint16(local+28,true),start=local+30+n+x; if(start+size>central||decoder.decode(bytes.subarray(local+30,local+30+n))!==name)throw new Error('Invalid bundle data extent'); const data=bytes.subarray(start,start+size);if(crc32(data)!==crc||v.getUint32(local+14,true)!==crc)throw new Error('Corrupt bundle checksum'); diff --git a/src/library.js b/src/library.js index e8efcc8..2de0dc0 100644 --- a/src/library.js +++ b/src/library.js @@ -92,6 +92,7 @@ export async function installLibrary(app){ }catch(e){app.status('Project was not opened: '+e.message,true);}finally{file.value='';} }; app.onChange(p=>{ + for(const key of Object.keys(app.assets))if(!p.assets.includes(key))delete app.assets[key]; const revision=++saveRevision;clearTimeout(saveTimer);state.textContent='Draft has unsaved changes'; saveTimer=setTimeout(async()=>{try{await store.saveProject('current',p,Object.values(await app.prepareAssets(p)));if(revision===saveRevision)state.textContent='Draft saved locally';}catch(e){if(revision===saveRevision)state.textContent='Draft not saved · '+e.message;}},350); }); diff --git a/src/modulation-ui.js b/src/modulation-ui.js index 7624079..5f77a70 100644 --- a/src/modulation-ui.js +++ b/src/modulation-ui.js @@ -1,10 +1,22 @@ import { COLORS } from './model.js'; +import { drawSignal } from './canvas.js'; export function installModulation(app){ const el=app.element,panel=app.addPanel('Shape one sound with another'); const heading=el('p',{class:'hint'},'Choose a frozen audio clip as the modulator. It can follow volume, multiply the waveform, or bend pitch. Saved sounds are copied by content, so later edits to a pad do not change this sound.'); const grid=el('div',{class:'control-grid',id:'modulation-controls'}),formula=el('code',{class:'route-formula',id:'modulation-formula'}); - panel.append(heading,grid,formula); - let refreshToken=0; + const preview=el('canvas',{class:'preview','aria-label':'Modulator clip waveform'}),summary=el('p',{class:'hint',id:'mod-source-summary'}),audition=el('button',{id:'audition-source'},'Audition modulator'); + panel.append(heading,grid,formula,preview,summary,audition); + let refreshToken=0,auditioning=false; + audition.onclick=async()=>{ + app.stop();const token=app.playbackRevision,sourceId=app.project.modulation.sourceId; + try{ + await app.transport.enable();const a=app.assets[sourceId]||await app.store.get('audio',sourceId); + if(token!==app.playbackRevision)return;if(!a)throw new Error('Choose a modulator clip first'); + const samples=Float32Array.from(a.samples),edge=Math.min(Math.round(.005*a.sampleRate),Math.floor(samples.length/2)); + for(let i=0;ichange(e.target.value)},...values.map(([value,text])=>el('option',{value},text)));node.value=value;return node; @@ -52,9 +64,16 @@ export function installModulation(app){ :m.target==='oscillator'?'f(t) = basePitch(t) × 2^(D(t) × modulator(t) / 12); phase = integral of frequency' :'readhead(t) = integral of 2^(D(t) × modulator(t) / 12); y(t) = carrier(readhead(t)). Pitch and timing change together.'; if(!m.sourceLoop)formula.textContent+=' Outside the modulator clip: unchanged carrier.'; + audition.disabled=!m.sourceId; + if(m.sourceId){ + const a=app.assets[m.sourceId]||await app.store.get('audio',m.sourceId);if(token!==refreshToken)return; + if(a){let low=0,high=0,sum=0;for(const x of a.samples){low=Math.min(low,x);high=Math.max(high,x);sum+=x*x;} + drawSignal(preview,a.samples,{color:'#e8edf5'});summary.textContent=a.name+' · '+(a.samples.length/a.sampleRate).toFixed(3)+' s · min '+low.toFixed(3)+' · max '+high.toFixed(3)+' · RMS '+Math.sqrt(sum/a.samples.length).toFixed(3)+(m.mode==='pitch'?' · rate range '+(2**(m.semitones*low/12)).toFixed(2)+'–'+(2**(m.semitones*high/12)).toFixed(2)+'× before oscillator limits':''); + } + }else{drawSignal(preview,new Float32Array(1));summary.textContent='Choose an imported or saved clip. Preview above shows signed sample amplitude.';} if(focus)grid.querySelector('[data-mod-key="'+CSS.escape(focus)+'"]')?.focus(); } - app.onChange(()=>refresh().catch(e=>app.status(e.message,true)));app.onAssetsChanged=()=>refresh().catch(e=>app.status(e.message,true)); + app.onChange(()=>{if(auditioning){auditioning=false;app.stop();}refresh().catch(e=>app.status(e.message,true));});app.onAssetsChanged=()=>refresh().catch(e=>app.status(e.message,true)); const oldRefresh=app.library.refresh;app.library.refresh=async()=>{await oldRefresh();await refresh();}; // Soundboard operations publish a local event after their atomic transaction. window.addEventListener('sigint-library-change',()=>refresh().catch(e=>app.status(e.message,true))); diff --git a/src/render.js b/src/render.js index 642035e..03c2eeb 100644 --- a/src/render.js +++ b/src/render.js @@ -15,7 +15,7 @@ export function waveform(v) { for(let h=1;h<=source.length/2;h*=2)tables.push(reconstruct(c,source.length,Math.min(h,limit),true)); return {source,data,tables,limit,selected:c,dc:c.a[0]}; } -function pitchCurve(points,length,fallback) { +function pitchCurve(points,length,fallback,rate) { const nodes=points.length?points:[[0,fallback],[1,fallback]]; const knots=nodes.map(([x,f])=>[x*length,f]); if(knots[0][0]>0)knots.unshift([0,knots[0][1]]); @@ -25,11 +25,15 @@ function pitchCurve(points,length,fallback) { const dt=knots[i+1][0]-knots[i][0],q=Math.log(knots[i+1][1]/knots[i][1])/dt; slopes[i]=q;prefix[i+1]=prefix[i]+(Math.abs(q)<1e-12?knots[i][1]*dt:knots[i][1]*Math.expm1(q*dt)/q); } - let cursor=0; + let cursor=0,count=0;const state={hz:0,phase:0},steps=slopes.map(q=>({ratio:Math.exp(q/rate),cycles:Math.abs(q)<1e-12?1/rate:Math.expm1(q/rate)/q})); return t=>{ - while(cursors+(a.samples?.byteLength||0),0)>96*1024**2)throw new Error('Render exceeds the 128 MiB working-buffer budget'); - const raw=new Float64Array(n*OVERSAMPLE),voices=p.mode==='timeline'?[]:includedVoices(p).map(v=>({v,...waveform(v),curve:pitchCurve(v.pitch,T,v.hz),phase:v.phase})); - const warnings=new Set(); + if(n*OVERSAMPLE*8*(needsCopy?2:1)+n*(options.includeSource?12:4)+Object.values(assets).reduce((s,a)=>s+(a.samples?.byteLength||0),0)>96*1024**2)throw new Error('Render exceeds the 128 MiB working-buffer budget'); + const raw=new Float64Array(n*OVERSAMPLE),voices=p.mode==='timeline'?[]:includedVoices(p).map(v=>({v,...waveform(v),curve:pitchCurve(v.pitch,T,v.hz,internal),phase:v.phase})); + const warnings=new Set(),inactive={value:0,availability:0}; + if(voices.some(item=>Math.abs(item.dc)>1e-6))warnings.add('Oscillator DC removed before drawn gain'); if(p.mode==='timeline'){ raw.set(resolveStrokes(p.timeline.strokes,raw.length,false,false)); const dc=metrics(raw).dc;for(let i=0;ifollower?attack:release;follower=a*follower+(1-a)*abs; + if(enabled&&m.target==='oscillator'&&m.mode==='volume'){const abs=Math.abs(state.value),a=abs>follower?attack:release;follower=a*follower+(1-a)*abs;} let sum=raw[i]; for(const item of voices){ const v=item.v,base=p.mode==='envelope'?item.curve(t):{hz:v.hz,phase:v.hz*t}; @@ -76,13 +81,12 @@ export async function render(input,assets={},options={}) { const requested=base.hz*2**(depth*state.value/12); hz=clamp(requested,20,Math.min(20000,rate/2-1)); if(hz!==requested)warnings.add('Effective pitch limited to supported frequency range'); - phase=item.phase;item.phase+=hz/internal; + if(i>0)item.phase+=(item.previousHz+hz)/(2*internal);phase=item.phase;item.previousHz=hz; } const h=Math.floor((rate/2-1)/hz); - const index=h<1?-1:v.source==='sine'?(h1e-6)warnings.add('Oscillator DC removed before drawn gain'); + if(hsetTimeout(r,0)); + // Worker termination is immediate; only direct callers with an AbortSignal need event-loop yields. + if(options.signal)await new Promise(r=>setTimeout(r,0)); } if(route&&m.target!=='oscillator'){ const dry=needsCopy?raw.slice():raw; - let position=0;follower=0; + let position=0,previousRatio=1;follower=0; for(let block=0;blockfollower?attack:release;follower=a*follower+(1-a)*abs; + if(m.mode==='volume'){const abs=Math.abs(state.value),a=abs>follower?attack:release;follower=a*follower+(1-a)*abs;} const ratio=m.mode==='pitch'?2**(m.semitones*state.availability*state.value/12):1; + if(i>0)position+=(previousRatio+ratio)/(2*internal);previousRatio=ratio; let value=carrier?readClip(carrier.samples,position,carrier.sampleRate,m.carrierLoop):needsCopy?readClip(dry,position,internal,m.carrierLoop):dry[i]; if(m.mode==='ring')value*=1-amount+amount*state.value; if(m.mode==='volume')value*=1-amount+amount*follower; - raw[i]=value;position+=ratio/internal; + raw[i]=value; } options.progress?.(.5+.3*Math.min(1,(block+16384)/raw.length)); - await new Promise(r=>setTimeout(r,0)); + // Worker termination is immediate; only direct callers with an AbortSignal need event-loop yields. + if(options.signal)await new Promise(r=>setTimeout(r,0)); } } if(enabled&&m.mode==='pitch'&&m.target!=='oscillator')warnings.add('Varispeed changes clip pitch and timing together'); diff --git a/src/resample.js b/src/resample.js index 3c5edc8..06a6d1f 100644 --- a/src/resample.js +++ b/src/resample.js @@ -15,10 +15,16 @@ for(let p=0;p<=PHASES;p++){ export function readClip(values, seconds, rate, loop=false) { if(!values.length || !Number.isFinite(seconds))return 0; let position=seconds*rate; - if(loop)position=((position%values.length)+values.length)%values.length; + if(loop&&(position<0||position>=values.length))position=((position%values.length)+values.length)%values.length; else if(position<0||position>=values.length)return 0; const index=Math.floor(position),fraction=(position-index)*PHASES,p=Math.floor(fraction),blend=fraction-p; - let out=0; + let out=0;const start=index-(HALF-1),left=p*TAPS,right=(p+1)*TAPS,inverse=1-blend; + if(start>=0&&start+TAPS<=values.length){ + // Uniform 4× grids land on exact kernel phases; tolerate only floating-point round-off. + if(blend<1e-7||blend>1-1e-7){const base=blend<.5?left:right;for(let k=0;k=0&¢er+half= 1) return 0; const x = position * values.length, i = Math.floor(x), f = x-i; return values[i]*(1-f) + values[i+1label,.voice{min-width:0}.route-formula{overflow-wrap:anywhere}@media(max-width:700px){.control-grid,.voices-panel #voices{grid-template-columns:repeat(2,minmax(0,1fr))}.control-grid{padding:12px 0}.asset-row .hint{white-space:normal}.extra-panel h2{overflow-wrap:anywhere}} diff --git a/tests/browser.mjs b/tests/browser.mjs index 8ab38ec..b10d103 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -65,6 +65,21 @@ try { const route=await page.evaluate(()=>({target:window.testApp.project.modulation.oscillatorId,selected:window.testApp.project.selectedId})); assert.notEqual(route.target,route.selected); await page.getByRole('button',{name:'Play sound',exact:true}).click();await page.waitForFunction(()=>!!window.testApp.lastRender);await page.locator('#stop').click(); + const unchanged=await page.evaluate(()=>JSON.stringify(window.testApp.project)); + await page.getByLabel('Open project file').setInputFiles({name:'broken.json',mimeType:'application/json',buffer:Buffer.from('{}')}); + await page.waitForFunction(()=>document.querySelector('#status').textContent.startsWith('Project was not opened:')); + assert.equal(await page.evaluate(()=>JSON.stringify(window.testApp.project)),unchanged); + const recovery=await page.evaluate(async()=>{ + const app=window.testApp,before=app.library.entries.length,save=app.store.saveSound; + app.store.saveSound=async()=>{throw new DOMException('Quota fixture','QuotaExceededError');}; + try{await app.library.save(null);}finally{app.store.saveSound=save;} + const {Importer}=await import('/src/importer.js'),importer=new Importer(); + const file=new File([await(await fetch('/.fixtures/tone.wav')).arrayBuffer()],'tone.wav'); + await importer.load(file);const pending=importer.trim({start:0,end:.1});importer.cancel(); + let aborted=false;try{await pending;}catch(e){aborted=e.name==='AbortError';} + return {before,after:app.library.entries.length,status:document.querySelector('#status').textContent,aborted,worker:importer.worker}; + }); + assert.equal(recovery.before,recovery.after);assert.match(recovery.status,/not saved/);assert.equal(recovery.aborted,true);assert.equal(recovery.worker,null); const frozenId=await page.evaluate(()=>window.testApp.library.entries[0].audioId); await page.locator('#mod-source').selectOption(frozenId); await page.waitForFunction(id=>window.testApp.project.modulation.sourceId===id,frozenId); @@ -91,8 +106,34 @@ try { await fresh.getByRole('button',{name:'Play sound',exact:true}).click(); await fresh.waitForFunction(()=>!!window.testApp.lastRender);assert.ok(await fresh.evaluate(()=>window.testApp.lastRender.peak>0)); await portable.close(); + if(name==='chromium'){ + await page.addScriptTag({path:'node_modules/axe-core/axe.min.js'}); + const violations=await page.evaluate(async()=>{const r=await axe.run(document,{runOnly:{type:'tag',values:['wcag2a','wcag2aa','wcag21aa']}});return r.violations.map(v=>({id:v.id,impact:v.impact,nodes:v.nodes.map(n=>n.target)}));}); + assert.deepEqual(violations,[]); + const stress=await page.evaluate(async()=>{ + const app=window.testApp,{project}=await import('/src/model.js'); + for(let k=0;k<31;k++){ + const p=project();p.duration=10;p.name='Capacity '+k;p.voices[0].hz=220+k; + const samples=Float32Array.from({length:480000},(_,i)=>.3*Math.sin(2*Math.PI*p.voices[0].hz*i/48000)); + await app.store.saveSound(p,{samples,sampleRate:48000,duration:10},[]); + } + let pcmReads=0;const get=app.store.get.bind(app.store);app.store.get=(name,...args)=>{if(name==='audio')pcmReads++;return get(name,...args);}; + await app.library.refresh();const listingReads=pcmReads;app.store.get=get; + const entry=app.library.entries.find(e=>e.duration<1),times=[]; + await app.library.play(entry);app.stop(); + for(let i=0;i<100;i++){const t=performance.now();await app.library.play(entry);times.push(performance.now()-t);app.stop();} + await new Promise(r=>setTimeout(r,100)); + const nodes=app.transport.nodes.size; + const pending=app.play();app.stop();await pending; + await new Promise(r=>setTimeout(r,100)); + return {count:app.library.entries.length,listingReads,nodes,afterCancel:app.transport.nodes.size,padP95Ms:times.sort((a,b)=>a-b)[94]}; + }); + assert.equal(stress.count,32);assert.equal(stress.listingReads,0);assert.equal(stress.nodes,0);assert.equal(stress.afterCancel,0);console.log('BROWSER_STRESS:'+JSON.stringify(stress)); + await page.setViewportSize({width:390,height:844}); + const overflow=await page.evaluate(()=>({width:innerWidth,scroll:document.documentElement.scrollWidth,items:[...document.querySelectorAll('body *')].filter(n=>n.getBoundingClientRect().right>innerWidth+1).map(n=>n.tagName+'.'+n.className).slice(0,12)}));assert.ok(overflow.scroll<=overflow.width+1,JSON.stringify(overflow)); + } assert.deepEqual(errors,[]); - if(name==='chromium')console.log('VISUAL:'+((await page.screenshot({type:'jpeg',quality:55,fullPage:true})).toString('base64'))); + if(name==='chromium'){await page.setViewportSize({width:1440,height:1000});await page.evaluate(()=>scrollTo(0,0));console.log('VISUAL:'+((await page.screenshot({type:'jpeg',quality:60})).toString('base64')));} console.log(name+': draw, voices, envelopes, undo/redo, worker audio, and Stop passed'); }catch(error){if(page){console.log('APP_STATE:'+await page.evaluate(()=>JSON.stringify({status:document.querySelector('#status')?.textContent,transport:document.querySelector('#transport-state')?.textContent,hasRender:!!window.testApp?.lastRender,audioState:window.testApp?.transport.context?.state,sampleRate:window.testApp?.transport.context?.sampleRate})));console.log('VISUAL:'+((await page.screenshot({type:'jpeg',quality:50,fullPage:true})).toString('base64')));}throw error;}finally{await browser.close();} } diff --git a/tests/quality.test.mjs b/tests/quality.test.mjs new file mode 100644 index 0000000..07b6a5b --- /dev/null +++ b/tests/quality.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { project, voice, preset } from '../src/model.js'; +import { render } from '../src/render.js'; +import { downsample, decimationFilter } from '../src/resample.js'; +test('independent oscillator phases cancel and muted/solo selections remain silent',async()=>{ + const p=project();p.duration=.04;const other=voice(1);other.phase=.5;p.voices.push(other); + let out=await render(p);assert.ok(out.peak<1e-6); + p.voices.forEach(v=>{v.muted=true;v.solo=true;});out=await render(p);assert.ok(out.samples.every(x=>x===0)); +}); +test('low-pass rejects ring sum above Nyquist and retains difference tone',async()=>{ + const p=project();p.duration=.12;p.voices[0].hz=14000;p.voices[0].source='sine';p.assets=['mod'];p.modulation={...p.modulation,enabled:true,mode:'ring',depth:1,sourceId:'mod'}; + const assets={mod:{sampleRate:48000,samples:Float32Array.from({length:9600},(_,i)=>Math.sin(2*Math.PI*13000*i/48000))}}; + const result=await render(p,assets); + const mag=hz=>{let a=0,b=0;for(let i=960;i<4800;i++){a+=result.samples[i]*Math.cos(2*Math.PI*hz*i/48000);b+=result.samples[i]*Math.sin(2*Math.PI*hz*i/48000);}return 2*Math.hypot(a,b)/3840;}; + assert.ok(mag(1000)>.30);assert.ok(mag(21000)<.002); +}); +test('maximum numerical source stays finite and preserves one global attenuation',async()=>{ + const p=project();p.duration=.02;p.voices=Array.from({length:4},(_,i)=>{const v=voice(i);v.waveform.resolution=16384;v.waveform.strokes=preset('square');v.hz=200+i*11;return v;});p.selectedId=p.modulation.oscillatorId=p.voices[0].id; + const out=await render(p);assert.ok(out.attenuation<1);assert.ok(out.peak<=.950001);assert.ok(out.samples.every(Number.isFinite)); +}); + +test('geometric pitch stepping tracks an independent logarithmic sweep phase',async()=>{ + const p=project();p.mode='envelope';p.duration=.15;p.voices[0].source='sine';p.voices[0].pitch=[[0,200],[1,1000]]; + const r=await render(p),q=Math.log(5)/p.duration; + let squared=0,count=0; + for(let i=960;i<6000;i++){const t=i/48000,expected=.7*Math.sin(2*Math.PI*200*Math.expm1(q*t)/q);squared+=(r.samples[i]-expected)**2;count++;} + assert.ok(Math.sqrt(squared/count)<.001); +}); diff --git a/tests/safari.py b/tests/safari.py new file mode 100644 index 0000000..91f7ceb --- /dev/null +++ b/tests/safari.py @@ -0,0 +1,85 @@ +"""Real Safari smoke test using Apple's bundled WebDriver; no Selenium dependency.""" +import json, subprocess, time, urllib.request, urllib.error, platform + +server = subprocess.Popen(["node", "scripts/serve.mjs"]) +driver = subprocess.Popen(["/usr/bin/safaridriver", "--port", "4444"]) +session = None + +def request(method, path, body=None): + payload = None if body is None else json.dumps(body).encode() + req = urllib.request.Request("http://127.0.0.1:4444"+path, data=payload, method=method, headers={"Content-Type":"application/json"}) + try: + with urllib.request.urlopen(req, timeout=130) as result: + data=json.load(result) + except urllib.error.HTTPError as error: + raise RuntimeError(error.read().decode()) from error + value=data.get("value") + if isinstance(value, dict) and value.get("error"): raise RuntimeError(value) + return value + +def script(source, args=None, asynchronous=False): + return request("POST", "/session/"+session+"/execute/"+("async" if asynchronous else "sync"), {"script":source,"args":args or []}) + +def async_script(source): + value=script("const done=arguments[arguments.length-1];(async()=>{"+source+"})().then(done,e=>done({error:e.stack||e.message}));", asynchronous=True) + if isinstance(value, dict) and value.get("error"): raise RuntimeError(value["error"]) + return value + +def click(selector): + result=request("POST","/session/"+session+"/element",{"using":"css selector","value":selector}) + key=result.get("element-6066-11e4-a52e-4f735466cecf") or result.get("ELEMENT") + request("POST","/session/"+session+"/element/"+key+"/click",{}) + +try: + for _ in range(100): + try: + request("GET","/status") + urllib.request.urlopen("http://127.0.0.1:4173",timeout=1).close() + break + except Exception: time.sleep(.2) + result=request("POST","/session",{"capabilities":{"alwaysMatch":{"browserName":"Safari"}}}) + session=result["sessionId"] + print("SAFARI_CAPABILITIES:"+json.dumps({"macOS":platform.mac_ver()[0],"machine":platform.machine(),"capabilities":result["capabilities"]}),flush=True) + request("POST","/session/"+session+"/timeouts",{"script":120000,"pageLoad":30000,"implicit":0}) + request("POST","/session/"+session+"/url",{"url":"http://127.0.0.1:4173"}) + async_script("window.testApp=(await import('/src/app.js')).app;await testApp.ready;testApp.edit(p=>{p.duration=.08;p.name='Safari fixture';});return true;") + codecs=async_script(""" + const {Importer}=await import('/src/importer.js'),{makeAsset}=await import('/src/storage.js'); + const results=[]; + for(const type of ['wav','mp3']){ + const bytes=await (await fetch('/.fixtures/tone.'+type)).arrayBuffer(),importer=new Importer(); + const info=await importer.load(new File([bytes],'tone.'+type)); + const trimmed=await importer.trim({start:0,end:.1,channel:'mix',sampleRate:48000});importer.cancel(); + const asset=await makeAsset(trimmed.samples,48000,'Safari '+type,trimmed.provenance); + await testApp.store.transaction(['audio','audioMeta'],'readwrite',tx=>testApp.store.putAsset(tx,asset)); + testApp.assets[asset.id]=asset;testApp.edit(p=>{p.assets.push(asset.id);p.modulation.sourceId=asset.id;}); + results.push({type,rate:info.sampleRate,channels:info.channels,frames:asset.frames}); + } + testApp.edit(p=>{p.modulation.enabled=true;p.modulation.mode='ring';p.modulation.depth=1;}); + return results; + """) + assert all(c["rate"]==44100 and c["channels"]==2 and c["frames"]==4800 for c in codecs),codecs + click("#play") + for _ in range(150): + state=script("return {render:!!testApp.lastRender,status:document.querySelector('#status').textContent};") + if state["render"]: break + time.sleep(.1) + assert state["render"],state + audio=script("return {peak:testApp.lastRender.peak,rate:testApp.lastRender.sampleRate,state:testApp.transport.context.state};") + assert 0{p.voices[0].hz=880;}); + const snapshot=await testApp.store.get('projects',entry.projectId); + return {saved:snapshot.project.voices[0].hz,current:testApp.project.voices[0].hz}; + """) + assert saved=={"saved":440,"current":880},saved + print("SAFARI_RESULT:"+json.dumps({"codecs":codecs,"audio":audio,"snapshotIsolation":saved}),flush=True) +finally: + if session: + try: request("DELETE","/session/"+session) + except Exception: pass + driver.terminate() + server.terminate()