jevstrudel.git / website / src / repl / audiograph.mjs

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

8import { logger } from '@strudel/core';
9import { getAudioContext, getSuperdoughAudioController, webaudioOutput } from '@strudel/webaudio';
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

120  let gd = '';
121  gd += '---\n';
122  gd += 'config:\n';
123  gd += '  flowchart:\n';
124  gd += '    wrappingWidth: ' + 14 * maxLineLength + '\n';
125  gd += '---\n';
126  gd += 'flowchart TB\n';
127  gd += '\tsubgraph AG[STRUDEL AUDIOGRAPH]\n';

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

257  let idx = 0;
258  const escapeHtml = (unsafe) => {
259    return unsafe
260      .replace(/&/g, '&amp;')
261      .replace(/</g, '&lt;')
262      .replace(/>/g, '&gt;')
263      .replace(/"/g, '&quot;')
264      .replace(/'/g, '&#039;');
265  };
266  svg = svg.replaceAll(codePlaceholder, () => escapeHtml(codeLines[idx++]));

improve sizing on web page

269  svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
270  element.innerHTML = svg;

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

370  if (pzViewport) {
371    pzViewport.setAttribute('transform', transform);
372    pzViewport.style = style;
373  }

trigger download

376  var blob = new Blob([content], { type: 'image/svg+xml' }),
377    url = window.URL.createObjectURL(blob);
378  a.href = url;
379  a.download = 'audiograph.svg';
380  a.click();
381  window.URL.revokeObjectURL(url);
382};
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

395  const destination = getAudioContext().destination;
396  if (destination._audioid) delete destination._audioid;
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

472    lazyRegister(AudioNode);
473    lazyRegister(AudioParam);
474    lazyRegister(PeriodicWave);
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)

552    resetAudioOutput(audioid);
553    // the second reset has the correct audioid cutoff
554    resetAudioOutput(audioid);

////////////////////////////////////// step 2: Load external modules //////////////////////////////////////

560    const { default: mermaidModule } = await import(
561      'https://cdn.jsdelivr.net/npm/mermaid@11.12.1/dist/mermaid.esm.mjs'
562    );
563    mermaid = mermaidModule;
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

630  all((pat) =>
631    pat.onTrigger(async (hap, duration, cps, t) => {
632      hap_count++;
633      const key = Object.entries(hap.value)
634        .map((param) => param.join('/'))
635        .join('/');

if we reached StopAfterHapCount, click 'stop'

638      if (StopAfterHapCount && hap_count > StopAfterHapCount) {
639        if (running) {
640          await sm.toggle();
641        }
642        // stop sending haps to superdough(...)
643        return;
644      }
646      await webaudioOutput(hap, t, hap.duration / cps, cps, t);
647
648      if (hapsBatch && hap_count % hapsBatch === 0) drawDiagram();
649    }),
650  );
651};