Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: |
Expand All @@ -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
22 changes: 22 additions & 0 deletions scripts/benchmark.mjs
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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);},
Expand Down Expand Up @@ -60,12 +61,13 @@ export const app={
}
};

$('#app').innerHTML="<header class=\"topbar\"><a class=\"brand\" href=\"./\" aria-label=\"SIGINT home\">◒ SIGINT<span>Signal sketchpad</span></a><div class=\"transport\"><button id=\"play\" class=\"primary\" aria-label=\"Play sound\">▶ Play</button><button id=\"stop\">■ Stop</button><label class=\"monitor\">Monitor <input id=\"monitor\" type=\"range\" min=\"0\" max=\"1\" step=\".01\" value=\".1\" aria-label=\"Monitor volume\"><output id=\"monitor-value\">10%</output></label><span id=\"transport-state\">Stopped</span></div><div id=\"file-actions\" class=\"actions\"></div></header>\n<main id=\"workspace\">\n<div class=\"workspace-heading\"><div><p class=\"eyebrow\">DRAW → UNDERSTAND → HEAR</p><h1>Your next sound starts with a line.</h1></div><label class=\"project-name\">Sound name<input id=\"project-name\" maxlength=\"80\" value=\"Untitled sound\"></label></div>\n<div class=\"workspace-grid\">\n<aside class=\"panel voices-panel\"><div class=\"section-heading\"><h2>Oscillators</h2><button id=\"add-voice\" aria-label=\"Add oscillator\">+</button></div><p class=\"hint\">One color. One voice. Your mix.</p><div id=\"voices\"></div><label class=\"check\"><input id=\"only\" type=\"checkbox\">Show selected voice only</label></aside>\n<section class=\"panel editor-panel\">\n<div class=\"tabs\" role=\"tablist\" aria-label=\"Workspace mode\"><button data-mode=\"waveform\" role=\"tab\">Waveform</button><button data-mode=\"envelope\" role=\"tab\">Envelopes</button><button data-mode=\"timeline\" role=\"tab\">Timeline</button></div>\n<div class=\"tool-row\"><div class=\"segmented\" aria-label=\"Drawing tools\"><button data-tool=\"pencil\">Pencil</button><button data-tool=\"line\">Line</button><button data-tool=\"eraser\">Eraser</button></div><button id=\"undo\" aria-label=\"Undo last edit\">↶ Undo</button><button id=\"redo\" aria-label=\"Redo last edit\">↷ Redo</button><button id=\"clear\">Clear</button><label class=\"check\"><input id=\"snap\" type=\"checkbox\">Snap</label></div>\n<div class=\"plot-heading\"><span id=\"canvas-title\">Waveform · signed amplitude</span><span id=\"time-extent\"></span></div>\n<div class=\"canvas-wrap\"><span class=\"axis-label\">+1<br><span>0</span><br>−1</span><canvas id=\"wave-canvas\" tabindex=\"0\" aria-label=\"Waveform drawing canvas. Numeric point controls are below.\"></canvas></div>\n<div class=\"axis-footer\"><span>Time →</span><span id=\"drawing-note\"></span></div>\n<div id=\"envelopes\" hidden><div class=\"plot-heading\"><span>Pitch · 20–20,000 Hz · logarithmic</span><span class=\"envelope-extent\"></span></div><canvas id=\"pitch-canvas\" class=\"lane\" tabindex=\"0\" aria-label=\"Pitch envelope drawing canvas\"></canvas><div class=\"plot-heading\"><span>Volume · linear gain 0–1</span><span class=\"envelope-extent\"></span></div><canvas id=\"gain-canvas\" class=\"lane\" tabindex=\"0\" aria-label=\"Volume envelope drawing canvas\"></canvas><p class=\"hint\">Pitch changes cycle spacing. Volume scales both sides of zero. Moving a waveform vertically adds DC.</p></div>\n<div class=\"viewport\"><label>Zoom<input id=\"zoom\" type=\"range\" min=\"1\" max=\"16\" step=\".5\" value=\"1\"></label><label>Position<input id=\"pan\" type=\"range\" min=\"0\" max=\"0\" step=\".001\" value=\"0\"></label><output id=\"zoom-value\">1×</output></div>\n<div id=\"voice-controls\" class=\"control-grid\"></div>\n<details class=\"numeric\"><summary>Edit exact points without drawing</summary><form id=\"point-form\" class=\"control-grid\"><label>Lane<select id=\"point-lane\"><option value=\"waveform\">Waveform</option><option value=\"pitch\">Pitch</option><option value=\"gain\">Volume</option></select></label><label>Time (seconds)<input id=\"point-time\" type=\"number\" min=\"0\" step=\"any\" value=\"0\" required></label><label>Value<input id=\"point-value\" type=\"number\" step=\"any\" value=\"0\" required></label><button type=\"submit\">Set point</button></form><p class=\"hint\">Waveform: −1…1. Pitch: 20…20,000 Hz. Volume: 0…1. Undo restores every edit.</p></details>\n</section>\n<aside class=\"panel inspector\"><p class=\"eyebrow\">THE MATH BEHIND THE SOUND</p><h2>Signal inspector</h2><div id=\"formula\" class=\"formula\"></div><div id=\"metrics\" class=\"metrics\"></div><h3>What you hear</h3><p id=\"hearing\" class=\"hint\">Press Play to render the selected source.</p><canvas id=\"output-canvas\" class=\"preview\" aria-label=\"Rendered output waveform\"></canvas><p class=\"plot-caption\">Rendered time → · signed amplitude</p><canvas id=\"spectrum-canvas\" class=\"preview\" aria-label=\"Rendered frequency spectrum\"></canvas><p class=\"plot-caption\" id=\"spectrum-caption\">Frequency → Hz · linear component magnitude<br>Hann window · up to 8192 samples</p><label class=\"check\"><input id=\"bypass\" type=\"checkbox\">Compare without clip modulation</label><p class=\"hint\">Comparison restarts from the same initial phase and clip position.</p></aside>\n</div>\n<div class=\"status-bar\"><span id=\"status\" role=\"status\" aria-live=\"polite\">Draw on the canvas, then press Play. Sound stays on this device.</span><progress id=\"render-progress\" value=\"0\" max=\"1\" hidden aria-label=\"Audio render progress\"></progress></div>\n<div id=\"extensions\"></div>\n</main><footer>Built for curiosity. Made to stay local. <a href=\"https://github.com/CurtisTechSolutions/SIGINTTool\">Source & documentation</a></footer>";
$('#app').innerHTML="<header class=\"topbar\"><a class=\"brand\" href=\"./\" aria-label=\"SIGINT home\">◒ SIGINT<span>Signal sketchpad</span></a><div class=\"transport\"><button id=\"play\" class=\"primary\" aria-label=\"Play sound\">▶ Play</button><button id=\"stop\">■ Stop</button><label class=\"monitor\">Monitor <input id=\"monitor\" type=\"range\" min=\"0\" max=\"1\" step=\".01\" value=\".1\" aria-label=\"Monitor volume\"><output id=\"monitor-value\">10%</output></label><span id=\"transport-state\">Stopped</span></div><div id=\"file-actions\" class=\"actions\"></div></header>\n<main id=\"workspace\">\n<div class=\"workspace-heading\"><div><p class=\"eyebrow\">DRAW → UNDERSTAND → HEAR</p><h1>Your next sound starts with a line.</h1></div><label class=\"project-name\">Sound name<input id=\"project-name\" maxlength=\"80\" value=\"Untitled sound\"></label></div>\n<div class=\"workspace-grid\">\n<aside class=\"panel voices-panel\"><div class=\"section-heading\"><h2>Oscillators</h2><button id=\"add-voice\" aria-label=\"Add oscillator\">+</button></div><p class=\"hint\">One color. One voice. Your mix.</p><div id=\"voices\"></div><label class=\"check\"><input id=\"only\" type=\"checkbox\">Show selected voice only</label></aside>\n<section class=\"panel editor-panel\">\n<div class=\"tabs\" role=\"tablist\" aria-label=\"Workspace mode\"><button data-mode=\"waveform\" role=\"tab\">Waveform</button><button data-mode=\"envelope\" role=\"tab\">Envelopes</button><button data-mode=\"timeline\" role=\"tab\">Timeline</button></div>\n<div class=\"tool-row\"><div class=\"segmented\" aria-label=\"Drawing tools\"><button data-tool=\"pencil\">Pencil</button><button data-tool=\"line\">Line</button><button data-tool=\"eraser\">Eraser</button></div><button id=\"undo\" aria-label=\"Undo last edit\">↶ Undo</button><button id=\"redo\" aria-label=\"Redo last edit\">↷ Redo</button><button id=\"clear\">Clear</button><label class=\"check\"><input id=\"snap\" type=\"checkbox\">Snap</label></div>\n<div class=\"plot-heading\"><span id=\"canvas-title\">Waveform · signed amplitude</span><span id=\"time-extent\"></span></div>\n<div class=\"canvas-wrap\"><span class=\"axis-label\">+1<br><span>0</span><br>−1</span><canvas id=\"wave-canvas\" tabindex=\"0\" aria-label=\"Waveform drawing canvas. Numeric point controls are below.\"></canvas></div>\n<div class=\"axis-footer\"><span>Time →</span><span id=\"drawing-note\"></span></div>\n<div id=\"envelopes\" hidden><div class=\"plot-heading\"><span>Pitch · 20–20,000 Hz · logarithmic</span><span class=\"envelope-extent\"></span></div><canvas id=\"pitch-canvas\" class=\"lane\" tabindex=\"0\" aria-label=\"Pitch envelope drawing canvas\"></canvas><div class=\"plot-heading\"><span>Volume · linear gain 0–1</span><span class=\"envelope-extent\"></span></div><canvas id=\"gain-canvas\" class=\"lane\" tabindex=\"0\" aria-label=\"Volume envelope drawing canvas\"></canvas><p class=\"hint\">Pitch changes cycle spacing. Volume scales both sides of zero. Moving a waveform vertically adds DC.</p></div>\n<div class=\"viewport\"><label>Zoom<input id=\"zoom\" type=\"range\" min=\"1\" max=\"16\" step=\".5\" value=\"1\"></label><label>Position<input id=\"pan\" type=\"range\" min=\"0\" max=\"0\" step=\".001\" value=\"0\"></label><output id=\"zoom-value\">1×</output></div>\n<div id=\"voice-controls\" class=\"control-grid\"></div>\n<details class=\"numeric\"><summary>Edit exact points without drawing</summary><form id=\"point-form\" class=\"control-grid\"><label>Lane<select id=\"point-lane\"><option value=\"waveform\">Waveform</option><option value=\"pitch\">Pitch</option><option value=\"gain\">Volume</option></select></label><label>Time (seconds)<input id=\"point-time\" type=\"number\" min=\"0\" step=\"any\" value=\"0\" required></label><label>Value<input id=\"point-value\" type=\"number\" step=\"any\" value=\"0\" required></label><button type=\"submit\">Set point</button></form><p class=\"hint\">Waveform: −1…1. Pitch: 20…20,000 Hz. Volume: 0…1. Undo restores every edit.</p></details>\n</section>\n<aside class=\"panel inspector\"><p class=\"eyebrow\">THE MATH BEHIND THE SOUND</p><h2>Signal inspector</h2><div id=\"formula\" class=\"formula\"></div><label class=\"check\"><input id=\"model-overlay\" type=\"checkbox\">Overlay selected model · white dotted line</label><div id=\"metrics\" class=\"metrics\"></div><h3>What you hear</h3><p id=\"hearing\" class=\"hint\">Press Play to render the selected source.</p><canvas id=\"output-canvas\" class=\"preview\" aria-label=\"Rendered output waveform\"></canvas><p class=\"plot-caption\">Rendered time → · signed amplitude</p><canvas id=\"spectrum-canvas\" class=\"preview\" aria-label=\"Rendered frequency spectrum\"></canvas><p class=\"plot-caption\" id=\"spectrum-caption\">Frequency → Hz · linear component magnitude<br>Hann window · up to 8192 samples</p><label class=\"check\"><input id=\"bypass\" type=\"checkbox\">Compare without clip modulation</label><p class=\"hint\">Comparison restarts from the same initial phase and clip position.</p></aside>\n</div>\n<div class=\"status-bar\"><span id=\"status\" role=\"status\" aria-live=\"polite\">Draw on the canvas, then press Play. Sound stays on this device.</span><progress id=\"render-progress\" value=\"0\" max=\"1\" hidden aria-label=\"Audio render progress\"></progress></div>\n<div id=\"extensions\"></div>\n</main><footer>Built for curiosity. Made to stay local. <a href=\"https://github.com/CurtisTechSolutions/SIGINTTool\">Source & documentation</a></footer>";
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();
Expand Down Expand Up @@ -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){
Expand Down Expand Up @@ -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=>{
Expand Down
2 changes: 1 addition & 1 deletion src/canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading
Loading