audiograph.mjs - show a svg view of the web audio API graph built during a playback Copyright (C) 2025 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/website/src/repl/audiograph.mjs This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.
main entry point is debugAudiograph
11let mermaid = null; 12let svgPanZoom = null; 13 14let running = false; 15let hap_count = 0; 16 17let cache = new Map(); 18const initCache = JSON.stringify({ 19 connect: [], 20 where: [], 21 disconnectAll: 0, 22 disconnectOne: 0, 23 hasStop: false, 24 stopCount: 0, 25 ac: null, 26 creation: null, 27}); 28 29let toggleOrig; 30 31function stackTrace() { 32 var err = new Error(); 33 const stacktrace = err.stack; 34 const lines = stacktrace.split('\n'); 35 let lineIndex = lines.findIndex((line) => line !== 'Error' && !line.includes('audiograph.mjs')); 36 if (lines[lineIndex].includes('gainNode')) lineIndex++; 37 if (lines[lineIndex].includes('getWorklet')) lineIndex++; 38 const line = lines[lineIndex].replace(/\s*at\s/, '').replace('http', '@'); 39 let match; 40 match = line.match(/([^@]*)@.*packages(\/[^:]+:\d+:\d+)/); 41 if (match) { 42 return match[1].replace(/[^.:/a-zA-Z0-9]/g, '') + '@' + match[2].replace(/[^.:/a-zA-Z0-9]/g, ''); 43 } 44 return '@'; 45}
This captures all AudioNodes lazily
when an .audioid property is called
no solution was found to hook
AudioNode's constructor directly
51let audioid = 0; 52const lazyRegister = (o) => { 53 Object.defineProperty(o.prototype, 'audioid', { 54 get: function () { 55 if (!this._audioid) { 56 this._audioid = ++audioid; 57 const s = JSON.parse(initCache); 58 s.type = this.constructor.name === 'AudiographNode' ? this.constructor._parentClassName : this.constructor.name; 59 // special case for subclassed AudioNodes 60 // they are implemented in superdough but hard to get a reference on here 61 // they are not AudioScheduledSourceNodes anyway 62 if (['FeedbackDelayNode', 'VowelNode'].indexOf(s.type) === -1) { 63 s.hasStop = window[s.type].prototype instanceof AudioScheduledSourceNode; 64 } 65 s.ac = this.context?.constructor.name || 'AudioParam'; 66 s.creation = s.creation || stackTrace(); 67 cache.set(this._audioid, s); 68 } 69 return this._audioid; 70 }, 71 enumerable: false, 72 configurable: true, 73 }); 74};
extend a specific AudioNode's constructor necessary when creation is done direclty by calling the constructor eg: new GainNode(...)
80const audioNodeHook = (node) => { 81 const name = node.prototype.constructor.name; 82 const PatchedNode = class AudiographNode extends node { 83 constructor(...args) { 84 super(...args); 85 // trigger the lazy register 86 this._audioid = this.audioid; 87 } 88 }; 89 PatchedNode._parentClassName = name; 90 window[name] = PatchedNode; 91};
93const drawMessage = async function (message) { 94 const element = document.querySelector('.strudel-mermaid'); 95 let gd = ''; 96 gd += '---\n'; 97 gd += 'config:\n'; 98 gd += ' flowchart:\n'; 99 gd += ' wrappingWidth: 600\n'; 100 gd += '---\n'; 101 gd += 'flowchart LR\n'; 102 gd += 'id[' + message.replaceAll(' ', ' ') + ']\n'; 103 104 let { svg } = await mermaid.render('strudelSvgId', gd); 105 svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%'); 106 svg = svg.replaceAll(' ', ' '); 107 element.innerHTML = svg; 108}; 109 110const drawDiagram = async function () { 111 const element = document.querySelector('.strudel-mermaid'); 112 let code = window.strudelMirror.code; 113 code = code.replace(/^await debugAudiograph.*\n?/gm, ''); 114 code = '// date: ' + new Date().toISOString() + '\n\n' + code; 115 code = '// host: ' + document.location.hostname + '\n' + code; 116 const codeLines = code.split(/(?:\n|\r\n?)/); 117 const maxLineLength = codeLines.reduce((memo, line) => Math.max(memo, line.length), 0);
https://mermaid.js.org/syntax/flowchart.html
seed graph builder with all unconnected nodes
131 let lookup = []; 132 cache.forEach((v, k) => { 133 if (v.connect.length === 0) lookup.push(k); 134 }); 135 const relations = []; 136 let curRelations; 137 const zombieCount = 0; 138 const sourceLoc = (stack) => { 139 if (stack === '@') return stack; 140 return stack.replace('@', '\n').replace('/superdough/', '/'); 141 }; 142 const label = (s) => { 143 const source = s.creation ? '\n' + sourceLoc(s.creation) : ''; 144 let lb = '[' + '**' + s.type + '**' + source + ']'; 145 if (s.ac === 'OfflineAudioContext') lb = '[' + lb + ']'; 146 return lb; 147 }; 148 const isConnectLeak = (s) => { 149 return ( 150 ['AudioDestinationNode', 'AudioParam'].indexOf(s.type) === -1 && 151 s.disconnectAll === 0 && 152 s.connect.length > s.disconnectOne 153 ); 154 }; 155 const isStopLeak = (s) => { 156 return s.hasStop && s.stopCount === 0; 157 }; 158 do { 159 curRelations = relations.length; 160 lookup.slice().forEach((n) => { 161 cache.forEach((v, k) => { 162 if (v.connect.indexOf(n) !== -1) { 163 if (lookup.indexOf(k) === -1) lookup.push(k); 164 gd += v.connect 165 .map((i) => { 166 if (lookup.indexOf(i) === -1) lookup.push(i); 167 if (relations.indexOf(k + '-' + i) === -1) { 168 relations.push(k + '-' + i); 169 return ( 170 '\t\tnode' + 171 k + 172 label(v) + 173 ' -- ' + 174 sourceLoc(v.where[0]) + 175 ' --> node' + 176 i + 177 label(cache.get(i)) + 178 '\n' 179 ); 180 } 181 }) 182 .join(''); 183 } 184 if (k === n) { 185 gd += v.connect 186 .map((i) => { 187 if (lookup.indexOf(i) === -1) lookup.push(i); 188 if (relations.indexOf(k + '-' + i) === -1) { 189 relations.push(k + '-' + i); 190 return ( 191 '\t\tnode' + 192 k + 193 label(v) + 194 ' -- ' + 195 sourceLoc(v.where[0]) + 196 ' --> node' + 197 i + 198 label(cache.get(i)) + 199 '\n' 200 ); 201 } 202 }) 203 .join(''); 204 } 205 }); 206 }); 207 } while (relations.length > curRelations /*&& lookup.length < 100*/); 208 // add orphan nodes 209 const inRelation = '-' + relations.join('-') + '-'; 210 cache.forEach((v, k) => { 211 if (!inRelation.includes('-' + k + '-')) { 212 gd += '\t\tnode' + k + label(v) + '\n'; 213 } 214 });
216 const codePlaceholder = 'm'.repeat(maxLineLength); 217 gd += '\tsubgraph LEGEND\n'; 218 gd += '\t\tlegend1[in AudioContext]\n'; 219 gd += '\t\tlegend2[[in OfflineAudioContext]]\n'; 220 gd += '\t\tlegend3[not disconnected]\n'; 221 gd += '\t\tlegend4[AudioParam]\n'; 222 gd += '\t\tlegend5[AudioDestinationNode]\n'; 223 gd += '\t\tlegend6[not stopped]\n'; 224 gd += '\tend\n'; 225 gd += '\tsubgraph CODE[Strudel Code]\n'; 226 // we use a codePlaceholder to 227 // - avoid problems with special chars 228 // - stop mermaid to split lines on space with multiple tspans 229 // - force mermaid to prepare a sufficiently sized zone 230 gd += '\ncode[' + (codePlaceholder + '<br>').repeat(codeLines.length) + ']\n'; 231 gd += '\tend\n'; 232 gd += '\tend\n'; 233 gd += '\tclassDef audioparam fill:#6f6;\n'; 234 gd += '\tclassDef destination fill:#99f;\n'; 235 gd += '\tclassDef connectleak fill:#f96,stroke:#f00,stroke-width:2px;\n'; 236 gd += '\tclassDef stopleak fill:#f55,stroke:#f00,stroke-width:2px;\n'; 237 gd += '\tclass legend3 connectleak;\n'; 238 gd += '\tclass legend4 audioparam;\n'; 239 gd += '\tclass legend5 destination;\n'; 240 gd += '\tclass legend6 stopleak;\n'; 241 cache.forEach((v, k) => { 242 if (isConnectLeak(v)) { 243 gd += '\tclass node' + k + ' connectleak;\n'; 244 } else if (isStopLeak(v)) { 245 gd += '\tclass node' + k + ' stopleak;\n'; 246 } 247 if (v.type === 'AudioParam') { 248 gd += '\tclass node' + k + ' audioparam;\n'; 249 } 250 if (v.type === 'AudioDestinationNode') { 251 gd += '\tclass node' + k + ' destination;\n'; 252 } 253 }); 254 let { svg } = await mermaid.render('strudelSvgId', gd);
put real code in code zone
improve sizing on web page
align the code lines
273 let svgText = document.querySelector('[id^=flowchart-code] text'); 274 svgText.setAttributeNS(null, 'style', 'text-anchor: start;'); 275 const svgElement = document.querySelector('svg'); 276 const svgLabel = document.querySelector('svg [id^=flowchart-code] .label'); 277 const transformList = svgLabel.transform.baseVal; 278 const svgTransform = svgElement.createSVGTransform(); 279 const tspans = Array.from(document.querySelectorAll('[id^=flowchart-code] tspan.text-inner-tspan')); 280 let tspansMaxLength = tspans.reduce((memo, tspan) => Math.max(memo, tspan.getComputedTextLength()), 0); 281 svgTransform.setTranslate(-tspansMaxLength / 2, 0); 282 transformList.appendItem(svgTransform);
284 let doPan = false; 285 let eventsHandler; 286 let panZoom; 287 let mousepos; 288 289 eventsHandler = { 290 haltEventListeners: ['mousedown', 'mousemove', 'mouseup'], 291 mouseDownHandler: function (ev) { 292 if (event.target.className == '[object SVGAnimatedString]') { 293 doPan = true; 294 mousepos = { 295 x: ev.clientX, 296 y: ev.clientY, 297 }; 298 } 299 }, 300 mouseMoveHandler: function (ev) { 301 if (doPan) { 302 panZoom.panBy({ 303 x: ev.clientX - mousepos.x, 304 y: ev.clientY - mousepos.y, 305 }); 306 mousepos = { 307 x: ev.clientX, 308 y: ev.clientY, 309 }; 310 window.getSelection().removeAllRanges(); 311 } 312 }, 313 mouseUpHandler: function (ev) { 314 doPan = false; 315 }, 316 init: function (options) { 317 options.svgElement.addEventListener('mousedown', this.mouseDownHandler, false); 318 options.svgElement.addEventListener('mousemove', this.mouseMoveHandler, false); 319 options.svgElement.addEventListener('mouseup', this.mouseUpHandler, false); 320 }, 321 destroy: function (options) { 322 options.svgElement.removeEventListener('mousedown', this.mouseDownHandler, false); 323 options.svgElement.removeEventListener('mousemove', this.mouseMoveHandler, false); 324 options.svgElement.removeEventListener('mouseup', this.mouseUpHandler, false); 325 }, 326 }; 327 panZoom = svgPanZoom('#strudelSvgId', { 328 zoomEnabled: true, 329 controlIconsEnabled: true, 330 fit: 1, 331 center: 1, 332 zoomScaleSensitivity: 0.4, 333 customEventsHandler: eventsHandler, 334 }); 335}; 336 337const svgExport = async () => { 338 const a = document.createElement('a'); 339 document.body.appendChild(a); 340 a.style = 'display: none'; 341 342 const selector = '.strudel-mermaid'; 343 const bbox = document.querySelector('svg g').getBBox(); 344 let transform, style; 345 // clean pan-zoom viewport 346 const pzViewport = document.querySelector('.svg-pan-zoom_viewport'); 347 if (pzViewport) { 348 transform = pzViewport.transform; 349 style = pzViewport.style; 350 pzViewport.setAttribute('transform', ''); 351 pzViewport.style = ''; 352 } 353 354 const spzMin = await fetch('https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js').then((res) => 355 res.text(), 356 ); 357 358 const scriptContent = '<![CDATA[' + spzMin + ';svgPanZoom("svg");]]>'; 359 // prepare svg 360 const content = document 361 .querySelector(selector) 362 .innerHTML.replaceAll('<br>', '<br/>') 363 // remove useless tags 364 .replace(/<g id="svg-pan-zoom.*<\/g>/, '<script>' + scriptContent + '</script>') 365 .replace(/<defs>.*<\/defs>/, '') 366 // give inkscape true sizes 367 .replace('width="100%"', 'width="' + bbox.width + '" height="' + bbox.height + '"');
restore pan-zoom viewport
trigger download
384const resetAudioOutput = function (audioid) { 385 // calling reset on SuperdoughAudioController 386 // will discard output nodes AND recreate them 387 // so we keep the same `cache` to handle the 388 // `disconnects` knowing that new nodes will be 389 // stricly after the current audioid. 390 // then we purge the old nodes from the `cache` 391 // to have a clean state
make sure destination will be recreated in the cache
398 const sac = getSuperdoughAudioController(); 399 sac.reset(); 400 Array.from(cache.keys()).map((k) => { 401 if (k <= audioid) cache.delete(k); 402 }); 403}; 404 405const postProcessing = async function () { 406 hap_count = 0; 407 await drawDiagram(); 408 409 resetAudioOutput(audioid); 410}; 411 412const defaultOptions = { 413 StopAfterHapCount: 10, 414 hapsBatch: 0, 415 maxEdges: 10000, 416 maxTextSize: 200000, 417 audioAPIBreathingRoomSec: 5, 418};
StopAfterHapCount :
The player will auto-stop after hap count have
been played. when StopAfterHapCount = 0, it will
continue playing until 'stop' is clicked
audioAPIBreathingRoomSec :
how much time should we wait after 'stop' to let
the audioAPI finish its tail of ondended calls
hapsBatch :
the AudioGraph will be displayed every hapsBatch haps
when hapsBatch = 0, AudioGraph will only be displayed
after and auto-stop or after 'stop' is clicked
In hapsBatch mode you will probably see a trailing of
non disconnected notes on the graph because the audio
API may have some lag disconnecting them
cf also audioAPIBreathingRoomSec
maxEdges
This is a mermaid.js config that forces a hard limit
on the maximum number of Edges of a graph
needs a reload to be taken into account
maxTextSize
This is a mermaid.js config that forces a hard limit
on the maximum text size of a graph definition
needs a reload to be taken into account
444export const debugAudiograph = async (argOptions = {}) => { 445 const options = Object.assign({}, defaultOptions, argOptions); 446 const { StopAfterHapCount, hapsBatch, maxEdges, maxTextSize, audioAPIBreathingRoomSec } = options; 447 const sm = window.strudelMirror; 448 const code = sm.code; 449 if (!code.match(/await\s+debugAudiograph/)) { 450 throw new Error('you need to call `await debugAudiograph()` for audiograph to work'); 451 } 452 const emptyOptions = /await\s+debugAudiograph\(\)/.exec(code); 453 if (emptyOptions) { 454 const cutCode = emptyOptions.index + emptyOptions[0].length - 1; 455 const codeOptions = JSON.stringify({ StopAfterHapCount: StopAfterHapCount }).replaceAll('"', ''); 456 sm.setCode(code.slice(0, cutCode) + codeOptions + code.slice(cutCode)); 457 } 458 459 if (window.audiograph === undefined) { 460 const ag = (window.audiograph = {}); 461 462 toggleOrig = sm.toggle;
////////////////////////////////////// step 1: web audio api instrumentation //////////////////////////////////////
path AudioNode & AudioParam
to give them lazy ids
this captures both ac.createGain
and new GainNode(..) patterns
476 const audioNodes = [ 477 AudioBufferSourceNode, 478 AudioWorkletNode, 479 AnalyserNode, 480 BiquadFilterNode, 481 ChannelMergerNode, 482 ChannelSplitterNode, 483 ConstantSourceNode, 484 ConvolverNode, 485 DelayNode, 486 DynamicsCompressorNode, 487 GainNode, 488 IIRFilterNode, 489 OscillatorNode, 490 PannerNode, 491 StereoPannerNode, 492 WaveShaperNode, 493 ]; 494 audioNodes.map((n) => { 495 if (n.prototype instanceof AudioScheduledSourceNode) { 496 const stopOrig = n.prototype.stop; 497 n.prototype.stop = function (...args) { 498 // stop called 499 const result = stopOrig.call(this, ...args); 500 const s = cache.get(this.audioid); 501 s.stopCount++; 502 return result; 503 }; 504 } 505 audioNodeHook(n); 506 });
patch BaseAudioContext factory methods to capture the source reference
510 Object.getOwnPropertyNames(BaseAudioContext.prototype) 511 .filter((n) => n.startsWith('create') && ['createBuffer'].indexOf(n) === -1) 512 .map((name) => { 513 const orig = BaseAudioContext.prototype[name]; 514 BaseAudioContext.prototype[name] = function (...args) { 515 const result = orig.call(this, ...args); 516 const s = cache.get(result.audioid); 517 s.creation = stackTrace(); 518 return result; 519 }; 520 });
522 const connectOrig = AudioNode.prototype.connect; 523 AudioNode.prototype.connect = function (destination, ...args) { 524 const result = connectOrig.call(this, destination, ...args); 525 const s = cache.get(this.audioid); 526 s.connect.push(destination.audioid); 527 s.where.push(stackTrace()); 528 return result; 529 }; 530 531 const disconnectOrig = AudioNode.prototype.disconnect; 532 AudioNode.prototype.disconnect = function (destination, ...args) { 533 const result = disconnectOrig.call(this, destination, ...args); 534 const s = cache.get(this.audioid); 535 if (s.connect.length) { 536 if (destination) { 537 s.disconnectOne++; 538 } else { 539 s.disconnectAll++; 540 } 541 } else { 542 logger('WEIRD: node ' + this.audioid + 'called disconnect before any call to connect !'); 543 //logger(new Error().stack); 544 console.log(cache); 545 } 546 return result; 547 };
call reset 2 times to handle reload + 'play' the first reset's disconnect adds audioid tags on previous outputs that were not tagged (wrong cutoff)
////////////////////////////////////// step 2: Load external modules //////////////////////////////////////
565 mermaid.initialize({ 566 startOnLoad: false, 567 themeCSS: '.flowchart { height: 100%; }', 568 maxEdges: maxEdges, 569 maxTextSize: maxTextSize, 570 htmlLabels: false, 571 flowchart: { 572 htmlLabels: false, 573 }, 574 }); 575 576 const { default: svgPanZoomModule } = await import('https://esm.sh/svg-pan-zoom'); 577 svgPanZoom = svgPanZoomModule;
//////////////////////////////////////// step 3: UI modifications ////////////////////////////////////////
add audiograph panel
584 if (!document.querySelector('.strudel-mermaid')) { 585 const mermaidDiv = document.createElement('div'); 586 mermaidDiv.className = 'strudel-mermaid'; 587 mermaidDiv.style = 'min-height: 600px; width: 60%'; 588 const referenceNode = document.querySelector('#code'); 589 referenceNode.parentNode.insertBefore(mermaidDiv, referenceNode.nextSibling); 590 }
add svg export button
593 if (!document.querySelector('button[title=svg]')) { 594 const exportButton = document.createElement('button'); 595 exportButton.innerHTML = '<span>ExportDiagram</span>'; 596 exportButton.title = 'svg'; 597 exportButton.onclick = svgExport; 598 const updateButton = document.querySelector('button[title=update]'); 599 updateButton.parentNode.insertBefore(exportButton, updateButton); 600 } 601 }
603 if (!running) { 604 running = true; 605 } 606 607 if (hapsBatch === 0 || hap_count < hapsBatch) { 608 let msg = ''; 609 msg += 'Recording activity...'; 610 msg += '\npress stop to build diagram'; 611 if (StopAfterHapCount) { 612 msg += '\nwill stop automatically in ' + Math.max(StopAfterHapCount - hap_count, 0) + ' haps'; 613 } 614 await drawMessage(msg); 615 } 616 617 sm.toggle = async () => { 618 running = false; 619 sm.toggle = toggleOrig; 620 // schedule `toggle` on the js main loop 621 // to avoid interfering with any on-flight onTick 622 // not doing this can lead to a phase > 0 which will 623 // break the next start 624 setTimeout(sm.toggle.bind(sm), 0); 625 await drawMessage('please wait ' + audioAPIBreathingRoomSec + ' seconds\n' + 'the audio API is finishing its work'); 626 setTimeout(postProcessing, audioAPIBreathingRoomSec * 1000); 627 };
global all
if we reached StopAfterHapCount, click 'stop'