Merge branch 'main' into eefano-brotli

This commit is contained in:
Felix Roos
2025-12-20 23:15:33 +01:00
29 changed files with 1833 additions and 108 deletions
@@ -142,6 +142,8 @@ The "~" represents a rest, and will create silence between other events:
<MiniRepl client:idle tune={`note("[b4 [~ c5] d5 e5]")`} punchcard />
Alternatively, "-" can be used instead of "~". It means the same thing.
## Parallel / polyphony
Using commas, we can play chords.
+651
View File
@@ -0,0 +1,651 @@
/*
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`
import { logger } from '@strudel/core';
import { getAudioContext, getSuperdoughAudioController, webaudioOutput } from '@strudel/webaudio';
let mermaid = null;
let svgPanZoom = null;
let running = false;
let hap_count = 0;
let cache = new Map();
const initCache = JSON.stringify({
connect: [],
where: [],
disconnectAll: 0,
disconnectOne: 0,
hasStop: false,
stopCount: 0,
ac: null,
creation: null,
});
let toggleOrig;
function stackTrace() {
var err = new Error();
const stacktrace = err.stack;
const lines = stacktrace.split('\n');
let lineIndex = lines.findIndex((line) => line !== 'Error' && !line.includes('audiograph.mjs'));
if (lines[lineIndex].includes('gainNode')) lineIndex++;
if (lines[lineIndex].includes('getWorklet')) lineIndex++;
const line = lines[lineIndex].replace(/\s*at\s/, '').replace('http', '@');
let match;
match = line.match(/([^@]*)@.*packages(\/[^:]+:\d+:\d+)/);
if (match) {
return match[1].replace(/[^.:/a-zA-Z0-9]/g, '') + '@' + match[2].replace(/[^.:/a-zA-Z0-9]/g, '');
}
return '@';
}
// This captures all AudioNodes lazily
// when an `.audioid` property is called
// no solution was found to hook
// AudioNode's constructor directly
let audioid = 0;
const lazyRegister = (o) => {
Object.defineProperty(o.prototype, 'audioid', {
get: function () {
if (!this._audioid) {
this._audioid = ++audioid;
const s = JSON.parse(initCache);
s.type = this.constructor.name === 'AudiographNode' ? this.constructor._parentClassName : this.constructor.name;
// special case for subclassed AudioNodes
// they are implemented in superdough but hard to get a reference on here
// they are not AudioScheduledSourceNodes anyway
if (['FeedbackDelayNode', 'VowelNode'].indexOf(s.type) === -1) {
s.hasStop = window[s.type].prototype instanceof AudioScheduledSourceNode;
}
s.ac = this.context?.constructor.name || 'AudioParam';
s.creation = s.creation || stackTrace();
cache.set(this._audioid, s);
}
return this._audioid;
},
enumerable: false,
configurable: true,
});
};
// extend a specific AudioNode's constructor
// necessary when creation is done direclty by
// calling the constructor
// eg: new GainNode(...)
const audioNodeHook = (node) => {
const name = node.prototype.constructor.name;
const PatchedNode = class AudiographNode extends node {
constructor(...args) {
super(...args);
// trigger the lazy register
this._audioid = this.audioid;
}
};
PatchedNode._parentClassName = name;
window[name] = PatchedNode;
};
const drawMessage = async function (message) {
const element = document.querySelector('.strudel-mermaid');
let gd = '';
gd += '---\n';
gd += 'config:\n';
gd += ' flowchart:\n';
gd += ' wrappingWidth: 600\n';
gd += '---\n';
gd += 'flowchart LR\n';
gd += 'id[' + message.replaceAll(' ', '&nbsp;') + ']\n';
let { svg } = await mermaid.render('strudelSvgId', gd);
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
svg = svg.replaceAll('&amp;nbsp;', ' ');
element.innerHTML = svg;
};
const drawDiagram = async function () {
const element = document.querySelector('.strudel-mermaid');
let code = window.strudelMirror.code;
code = code.replace(/^await debugAudiograph.*\n?/gm, '');
code = '// date: ' + new Date().toISOString() + '\n\n' + code;
code = '// host: ' + document.location.hostname + '\n' + code;
const codeLines = code.split(/(?:\n|\r\n?)/);
const maxLineLength = codeLines.reduce((memo, line) => Math.max(memo, line.length), 0);
// https://mermaid.js.org/syntax/flowchart.html
let gd = '';
gd += '---\n';
gd += 'config:\n';
gd += ' flowchart:\n';
gd += ' wrappingWidth: ' + 14 * maxLineLength + '\n';
gd += '---\n';
gd += 'flowchart TB\n';
gd += '\tsubgraph AG[STRUDEL AUDIOGRAPH]\n';
// seed graph builder with all
// unconnected nodes
let lookup = [];
cache.forEach((v, k) => {
if (v.connect.length === 0) lookup.push(k);
});
const relations = [];
let curRelations;
const zombieCount = 0;
const sourceLoc = (stack) => {
if (stack === '@') return stack;
return stack.replace('@', '\n').replace('/superdough/', '/');
};
const label = (s) => {
const source = s.creation ? '\n' + sourceLoc(s.creation) : '';
let lb = '[' + '**' + s.type + '**' + source + ']';
if (s.ac === 'OfflineAudioContext') lb = '[' + lb + ']';
return lb;
};
const isConnectLeak = (s) => {
return (
['AudioDestinationNode', 'AudioParam'].indexOf(s.type) === -1 &&
s.disconnectAll === 0 &&
s.connect.length > s.disconnectOne
);
};
const isStopLeak = (s) => {
return s.hasStop && s.stopCount === 0;
};
do {
curRelations = relations.length;
lookup.slice().forEach((n) => {
cache.forEach((v, k) => {
if (v.connect.indexOf(n) !== -1) {
if (lookup.indexOf(k) === -1) lookup.push(k);
gd += v.connect
.map((i) => {
if (lookup.indexOf(i) === -1) lookup.push(i);
if (relations.indexOf(k + '-' + i) === -1) {
relations.push(k + '-' + i);
return (
'\t\tnode' +
k +
label(v) +
' -- ' +
sourceLoc(v.where[0]) +
' --> node' +
i +
label(cache.get(i)) +
'\n'
);
}
})
.join('');
}
if (k === n) {
gd += v.connect
.map((i) => {
if (lookup.indexOf(i) === -1) lookup.push(i);
if (relations.indexOf(k + '-' + i) === -1) {
relations.push(k + '-' + i);
return (
'\t\tnode' +
k +
label(v) +
' -- ' +
sourceLoc(v.where[0]) +
' --> node' +
i +
label(cache.get(i)) +
'\n'
);
}
})
.join('');
}
});
});
} while (relations.length > curRelations /*&& lookup.length < 100*/);
// add orphan nodes
const inRelation = '-' + relations.join('-') + '-';
cache.forEach((v, k) => {
if (!inRelation.includes('-' + k + '-')) {
gd += '\t\tnode' + k + label(v) + '\n';
}
});
const codePlaceholder = 'm'.repeat(maxLineLength);
gd += '\tsubgraph LEGEND\n';
gd += '\t\tlegend1[in AudioContext]\n';
gd += '\t\tlegend2[[in OfflineAudioContext]]\n';
gd += '\t\tlegend3[not disconnected]\n';
gd += '\t\tlegend4[AudioParam]\n';
gd += '\t\tlegend5[AudioDestinationNode]\n';
gd += '\t\tlegend6[not stopped]\n';
gd += '\tend\n';
gd += '\tsubgraph CODE[Strudel Code]\n';
// we use a codePlaceholder to
// - avoid problems with special chars
// - stop mermaid to split lines on space with multiple tspans
// - force mermaid to prepare a sufficiently sized zone
gd += '\ncode[' + (codePlaceholder + '<br>').repeat(codeLines.length) + ']\n';
gd += '\tend\n';
gd += '\tend\n';
gd += '\tclassDef audioparam fill:#6f6;\n';
gd += '\tclassDef destination fill:#99f;\n';
gd += '\tclassDef connectleak fill:#f96,stroke:#f00,stroke-width:2px;\n';
gd += '\tclassDef stopleak fill:#f55,stroke:#f00,stroke-width:2px;\n';
gd += '\tclass legend3 connectleak;\n';
gd += '\tclass legend4 audioparam;\n';
gd += '\tclass legend5 destination;\n';
gd += '\tclass legend6 stopleak;\n';
cache.forEach((v, k) => {
if (isConnectLeak(v)) {
gd += '\tclass node' + k + ' connectleak;\n';
} else if (isStopLeak(v)) {
gd += '\tclass node' + k + ' stopleak;\n';
}
if (v.type === 'AudioParam') {
gd += '\tclass node' + k + ' audioparam;\n';
}
if (v.type === 'AudioDestinationNode') {
gd += '\tclass node' + k + ' destination;\n';
}
});
let { svg } = await mermaid.render('strudelSvgId', gd);
// put real code in code zone
let idx = 0;
const escapeHtml = (unsafe) => {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
svg = svg.replaceAll(codePlaceholder, () => escapeHtml(codeLines[idx++]));
// improve sizing on web page
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
element.innerHTML = svg;
// align the code lines
let svgText = document.querySelector('[id^=flowchart-code] text');
svgText.setAttributeNS(null, 'style', 'text-anchor: start;');
const svgElement = document.querySelector('svg');
const svgLabel = document.querySelector('svg [id^=flowchart-code] .label');
const transformList = svgLabel.transform.baseVal;
const svgTransform = svgElement.createSVGTransform();
const tspans = Array.from(document.querySelectorAll('[id^=flowchart-code] tspan.text-inner-tspan'));
let tspansMaxLength = tspans.reduce((memo, tspan) => Math.max(memo, tspan.getComputedTextLength()), 0);
svgTransform.setTranslate(-tspansMaxLength / 2, 0);
transformList.appendItem(svgTransform);
let doPan = false;
let eventsHandler;
let panZoom;
let mousepos;
eventsHandler = {
haltEventListeners: ['mousedown', 'mousemove', 'mouseup'],
mouseDownHandler: function (ev) {
if (event.target.className == '[object SVGAnimatedString]') {
doPan = true;
mousepos = {
x: ev.clientX,
y: ev.clientY,
};
}
},
mouseMoveHandler: function (ev) {
if (doPan) {
panZoom.panBy({
x: ev.clientX - mousepos.x,
y: ev.clientY - mousepos.y,
});
mousepos = {
x: ev.clientX,
y: ev.clientY,
};
window.getSelection().removeAllRanges();
}
},
mouseUpHandler: function (ev) {
doPan = false;
},
init: function (options) {
options.svgElement.addEventListener('mousedown', this.mouseDownHandler, false);
options.svgElement.addEventListener('mousemove', this.mouseMoveHandler, false);
options.svgElement.addEventListener('mouseup', this.mouseUpHandler, false);
},
destroy: function (options) {
options.svgElement.removeEventListener('mousedown', this.mouseDownHandler, false);
options.svgElement.removeEventListener('mousemove', this.mouseMoveHandler, false);
options.svgElement.removeEventListener('mouseup', this.mouseUpHandler, false);
},
};
panZoom = svgPanZoom('#strudelSvgId', {
zoomEnabled: true,
controlIconsEnabled: true,
fit: 1,
center: 1,
zoomScaleSensitivity: 0.4,
customEventsHandler: eventsHandler,
});
};
const svgExport = async () => {
const a = document.createElement('a');
document.body.appendChild(a);
a.style = 'display: none';
const selector = '.strudel-mermaid';
const bbox = document.querySelector('svg g').getBBox();
let transform, style;
// clean pan-zoom viewport
const pzViewport = document.querySelector('.svg-pan-zoom_viewport');
if (pzViewport) {
transform = pzViewport.transform;
style = pzViewport.style;
pzViewport.setAttribute('transform', '');
pzViewport.style = '';
}
const spzMin = await fetch('https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js').then((res) =>
res.text(),
);
const scriptContent = '<![CDATA[' + spzMin + ';svgPanZoom("svg");]]>';
// prepare svg
const content = document
.querySelector(selector)
.innerHTML.replaceAll('<br>', '<br/>')
// remove useless tags
.replace(/<g id="svg-pan-zoom.*<\/g>/, '<script>' + scriptContent + '</script>')
.replace(/<defs>.*<\/defs>/, '')
// give inkscape true sizes
.replace('width="100%"', 'width="' + bbox.width + '" height="' + bbox.height + '"');
// restore pan-zoom viewport
if (pzViewport) {
pzViewport.setAttribute('transform', transform);
pzViewport.style = style;
}
// trigger download
var blob = new Blob([content], { type: 'image/svg+xml' }),
url = window.URL.createObjectURL(blob);
a.href = url;
a.download = 'audiograph.svg';
a.click();
window.URL.revokeObjectURL(url);
};
const resetAudioOutput = function (audioid) {
// calling reset on SuperdoughAudioController
// will discard output nodes AND recreate them
// so we keep the same `cache` to handle the
// `disconnects` knowing that new nodes will be
// stricly after the current audioid.
// then we purge the old nodes from the `cache`
// to have a clean state
// make sure destination will be recreated in the
// cache
const destination = getAudioContext().destination;
if (destination._audioid) delete destination._audioid;
const sac = getSuperdoughAudioController();
sac.reset();
Array.from(cache.keys()).map((k) => {
if (k <= audioid) cache.delete(k);
});
};
const postProcessing = async function () {
hap_count = 0;
await drawDiagram();
resetAudioOutput(audioid);
};
const defaultOptions = {
StopAfterHapCount: 10,
hapsBatch: 0,
maxEdges: 10000,
maxTextSize: 200000,
audioAPIBreathingRoomSec: 5,
};
// `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
export const debugAudiograph = async (argOptions = {}) => {
const options = Object.assign({}, defaultOptions, argOptions);
const { StopAfterHapCount, hapsBatch, maxEdges, maxTextSize, audioAPIBreathingRoomSec } = options;
const sm = window.strudelMirror;
const code = sm.code;
if (!code.match(/await\s+debugAudiograph/)) {
throw new Error('you need to call `await debugAudiograph()` for audiograph to work');
}
const emptyOptions = /await\s+debugAudiograph\(\)/.exec(code);
if (emptyOptions) {
const cutCode = emptyOptions.index + emptyOptions[0].length - 1;
const codeOptions = JSON.stringify({ StopAfterHapCount: StopAfterHapCount }).replaceAll('"', '');
sm.setCode(code.slice(0, cutCode) + codeOptions + code.slice(cutCode));
}
if (window.audiograph === undefined) {
const ag = (window.audiograph = {});
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
lazyRegister(AudioNode);
lazyRegister(AudioParam);
lazyRegister(PeriodicWave);
const audioNodes = [
AudioBufferSourceNode,
AudioWorkletNode,
AnalyserNode,
BiquadFilterNode,
ChannelMergerNode,
ChannelSplitterNode,
ConstantSourceNode,
ConvolverNode,
DelayNode,
DynamicsCompressorNode,
GainNode,
IIRFilterNode,
OscillatorNode,
PannerNode,
StereoPannerNode,
WaveShaperNode,
];
audioNodes.map((n) => {
if (n.prototype instanceof AudioScheduledSourceNode) {
const stopOrig = n.prototype.stop;
n.prototype.stop = function (...args) {
// stop called
const result = stopOrig.call(this, ...args);
const s = cache.get(this.audioid);
s.stopCount++;
return result;
};
}
audioNodeHook(n);
});
// patch BaseAudioContext factory methods
// to capture the source reference
Object.getOwnPropertyNames(BaseAudioContext.prototype)
.filter((n) => n.startsWith('create') && ['createBuffer'].indexOf(n) === -1)
.map((name) => {
const orig = BaseAudioContext.prototype[name];
BaseAudioContext.prototype[name] = function (...args) {
const result = orig.call(this, ...args);
const s = cache.get(result.audioid);
s.creation = stackTrace();
return result;
};
});
const connectOrig = AudioNode.prototype.connect;
AudioNode.prototype.connect = function (destination, ...args) {
const result = connectOrig.call(this, destination, ...args);
const s = cache.get(this.audioid);
s.connect.push(destination.audioid);
s.where.push(stackTrace());
return result;
};
const disconnectOrig = AudioNode.prototype.disconnect;
AudioNode.prototype.disconnect = function (destination, ...args) {
const result = disconnectOrig.call(this, destination, ...args);
const s = cache.get(this.audioid);
if (s.connect.length) {
if (destination) {
s.disconnectOne++;
} else {
s.disconnectAll++;
}
} else {
logger('WEIRD: node ' + this.audioid + 'called disconnect before any call to connect !');
//logger(new Error().stack);
console.log(cache);
}
return result;
};
// 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)
resetAudioOutput(audioid);
// the second reset has the correct audioid cutoff
resetAudioOutput(audioid);
////////////////////////////////////////
// step 2: Load external modules
////////////////////////////////////////
const { default: mermaidModule } = await import(
'https://cdn.jsdelivr.net/npm/mermaid@11.12.1/dist/mermaid.esm.mjs'
);
mermaid = mermaidModule;
mermaid.initialize({
startOnLoad: false,
themeCSS: '.flowchart { height: 100%; }',
maxEdges: maxEdges,
maxTextSize: maxTextSize,
htmlLabels: false,
flowchart: {
htmlLabels: false,
},
});
const { default: svgPanZoomModule } = await import('https://esm.sh/svg-pan-zoom');
svgPanZoom = svgPanZoomModule;
//////////////////////////////////////////
// step 3: UI modifications
//////////////////////////////////////////
// add audiograph panel
if (!document.querySelector('.strudel-mermaid')) {
const mermaidDiv = document.createElement('div');
mermaidDiv.className = 'strudel-mermaid';
mermaidDiv.style = 'min-height: 600px; width: 60%';
const referenceNode = document.querySelector('#code');
referenceNode.parentNode.insertBefore(mermaidDiv, referenceNode.nextSibling);
}
// add svg export button
if (!document.querySelector('button[title=svg]')) {
const exportButton = document.createElement('button');
exportButton.innerHTML = '<span>ExportDiagram</span>';
exportButton.title = 'svg';
exportButton.onclick = svgExport;
const updateButton = document.querySelector('button[title=update]');
updateButton.parentNode.insertBefore(exportButton, updateButton);
}
}
if (!running) {
running = true;
}
if (hapsBatch === 0 || hap_count < hapsBatch) {
let msg = '';
msg += 'Recording activity...';
msg += '\npress stop to build diagram';
if (StopAfterHapCount) {
msg += '\nwill stop automatically in ' + Math.max(StopAfterHapCount - hap_count, 0) + ' haps';
}
await drawMessage(msg);
}
sm.toggle = async () => {
running = false;
sm.toggle = toggleOrig;
// schedule `toggle` on the js main loop
// to avoid interfering with any on-flight onTick
// not doing this can lead to a phase > 0 which will
// break the next start
setTimeout(sm.toggle.bind(sm), 0);
await drawMessage('please wait ' + audioAPIBreathingRoomSec + ' seconds\n' + 'the audio API is finishing its work');
setTimeout(postProcessing, audioAPIBreathingRoomSec * 1000);
};
/*global all*/
all((pat) =>
pat.onTrigger(async (hap, duration, cps, t) => {
hap_count++;
const key = Object.entries(hap.value)
.map((param) => param.join('/'))
.join('/');
// if we reached StopAfterHapCount, click 'stop'
if (StopAfterHapCount && hap_count > StopAfterHapCount) {
if (running) {
await sm.toggle();
}
// stop sending haps to superdough(...)
return;
}
await webaudioOutput(hap, t, hap.duration / cps, cps, t);
if (hapsBatch && hap_count % hapsBatch === 0) drawDiagram();
}),
);
};
@@ -0,0 +1,197 @@
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import cx from '@src/cx.mjs';
import NumberInput from '@src/repl/components/NumberInput';
import { useEffect, useState } from 'react';
import { Textbox } from '../textbox/Textbox';
import { getAudioContext } from '@strudel/webaudio';
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
function Checkbox({ label, value, onChange, disabled = false }) {
return (
<label className={cx(disabled && 'opacity-50')}>
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
{' ' + label}
</label>
);
}
function FormItem({ label, children, disabled }) {
return (
<div className="grid gap-2 w-full">
<label className={cx(disabled && 'opacity-50')}>{label}</label>
{children}
</div>
);
}
export default function ExportTab(Props) {
const { handleExport } = Props;
const [downloadName, setDownloadName] = useState('');
const [startCycle, setStartCycle] = useState(0);
const [endCycle, setEndCycle] = useState(1);
const [sampleRate, setSampleRate] = useState(48000);
const [multiChannelOrbits, setMultiChannelOrbits] = useState(true);
const [maxPolyphony, setMaxPolyphony] = useState(1024);
const [exporting, setExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [length, setLength] = useState(1);
const refreshProgress = () => {
const audioContext = getAudioContext();
if (audioContext instanceof OfflineAudioContext) {
setProgress(audioContext.currentTime);
setLength(audioContext.length / sampleRate);
setTimeout(refreshProgress, 100);
}
};
return (
<>
<div className="text-foreground w-full p-4 space-y-4">
<FormItem label="File name" disabled={exporting}>
<Textbox
onBlur={(e) => {
setDownloadName(e.target.value);
}}
onChange={(v) => {
setDownloadName(v);
}}
disabled={exporting}
placeholder="Leave empty to use current date"
className={cx('placeholder:opacity-50', exporting && 'opacity-50 border-opacity-50')}
value={downloadName ?? ''}
/>
</FormItem>
<div className="flex flex-row gap-4 w-full">
<FormItem label="Start cycle" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? 0 : Math.max(0, v);
setStartCycle(v);
}}
onChange={(v) => {
v = parseInt(v);
setStartCycle(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
value={startCycle ?? ''}
/>
</FormItem>
<FormItem label="End cycle" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v;
setEndCycle(v);
}}
onChange={(v) => {
v = parseInt(v);
setEndCycle(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
value={endCycle ?? ''}
/>
</FormItem>
</div>
<div className="flex flex-row gap-4">
<FormItem label="Sample rate" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? 1 : Math.max(1, v);
setSampleRate(v);
}}
onChange={(v) => {
v = parseInt(v);
setSampleRate(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50')}
value={sampleRate ?? ''}
/>
</FormItem>
<FormItem label="Maximum polyphony" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? Math.max(1, parseInt(v)) : v;
setMaxPolyphony(v);
}}
onChange={(v) => {
v = Math.max(1, parseInt(v));
setMaxPolyphony(v);
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && 'opacity-50 border-opacity-50')}
value={maxPolyphony ?? ''}
/>
</FormItem>
</div>
<div>
<Checkbox
label="Multi Channel Orbits"
onChange={(cbEvent) => {
const val = cbEvent.target.checked;
setMultiChannelOrbits(val);
}}
disabled={exporting}
value={multiChannelOrbits}
/>
</div>
<button
className={cx('bg-background p-2 w-full rounded-md hover:opacity-75 relative', exporting && 'opacity-50')}
disabled={exporting}
onClick={async () => {
setExporting(true);
setTimeout(refreshProgress, 2000);
const modal = document.getElementById('exportProgressModal');
modal.showModal();
await handleExport(startCycle, endCycle, sampleRate, maxPolyphony, multiChannelOrbits, downloadName)
.then(() => {
const modal = document.getElementById('exportProgressModal');
modal.close();
})
.finally(() => {
setExporting(false);
setProgress(0);
setLength(1);
});
}}
>
<div
className="absolute top-0 left-0 right-0 bottom-0 backdrop-invert"
style={{
width: `${(exporting ? 1 : 0) + (progress / length) * 99}%`,
}}
/>
<span className="text-foreground">{exporting ? 'Exporting...' : 'Export to WAV'}</span>
</button>
</div>
<dialog
closedby={exporting ? 'none' : 'closerequest'}
id="exportProgressModal"
className="text-md bg-background text-foreground rounded-lg backdrop:bg-background backdrop:opacity-25"
/>
</>
);
}
@@ -9,6 +9,7 @@ import { useLogger } from '../useLogger';
import { WelcomeTab } from './WelcomeTab';
import { PatternsTab } from './PatternsTab';
import { ChevronLeftIcon, XMarkIcon } from '@heroicons/react/16/solid';
import ExportTab from './ExportTab';
const TAURI = typeof window !== 'undefined' && window.__TAURI__;
@@ -80,6 +81,7 @@ const tabNames = {
patterns: 'patterns',
sounds: 'sounds',
reference: 'reference',
export: 'export',
console: 'console',
settings: 'settings',
};
@@ -126,6 +128,8 @@ function PanelContent({ context, tab }) {
return <SoundsTab />;
case tabNames.reference:
return <Reference />;
case tabNames.export:
return <ExportTab handleExport={context.handleExport} />;
case tabNames.settings:
return <SettingsTab started={context.started} />;
case tabNames.files:
+19
View File
@@ -393,6 +393,8 @@ samples({
bass: { d2: 'https://cdn.freesound.org/previews/608/608286_13074022-lq.mp3' }
})
useRNG('legacy')
stack(
// bells
n("0").euclidLegato(3,8)
@@ -430,6 +432,7 @@ export const festivalOfFingers3 = `// "Festival of fingers 3"
// @by Felix Roos
setcps(1)
useRNG('legacy')
n("[-7*3],0,2,6,[8 7]")
.echoWith(
@@ -454,6 +457,8 @@ export const meltingsubmarine = `// "Melting submarine"
// @by Felix Roos
samples('github:tidalcycles/dirt-samples')
useRNG('legacy')
stack(
s("bd:5,[~ <sd:1!3 sd:1(3,4,3)>],hh27(3,4,1)") // drums
.speed(perlin.range(.7,.9)) // random sample speed variation
@@ -602,6 +607,9 @@ export const belldub = `// "Belldub"
samples({ bell: {b4:'https://cdn.freesound.org/previews/339/339809_5121236-lq.mp3'}})
// "Hand Bells, B, Single.wav" by InspectorJ (www.jshaw.co.uk) of Freesound.org
useRNG('legacy')
stack(
// bass
note("[0 ~] [2 [0 2]] [4 4*2] [[4 ~] [2 ~] 0@2]".scale('g1 dorian').superimpose(x=>x.add(.02)))
@@ -638,6 +646,7 @@ export const dinofunk = `// "Dinofunk"
// @by Felix Roos
setcps(1)
useRNG('legacy')
samples({bass:'https://cdn.freesound.org/previews/614/614637_2434927-hq.mp3',
dino:{b4:'https://cdn.freesound.org/previews/316/316403_5123851-hq.mp3'}})
@@ -666,6 +675,8 @@ export const sampleDemo = `// "Sample demo"
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
// @by Felix Roos
useRNG('legacy')
stack(
// percussion
s("[woodblock:1 woodblock:2*2] snare_rim:0,gong/8,brakedrum:1(3,8),~@3 cowbell:3")
@@ -684,6 +695,8 @@ export const holyflute = `// "Holy flute"
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
// @by Felix Roos
useRNG('legacy')
"c3 eb3(3,8) c4/2 g3*2"
.superimpose(
x=>x.slow(2).add(12),
@@ -699,6 +712,8 @@ export const flatrave = `// "Flatrave"
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
// @by Felix Roos
useRNG('legacy')
stack(
s("bd*2,~ [cp,sd]").bank('RolandTR909'),
@@ -727,6 +742,8 @@ export const amensister = `// "Amensister"
samples('github:tidalcycles/dirt-samples')
useRNG('legacy')
stack(
// amen
n("0 1 2 3 4 5 6 7")
@@ -834,6 +851,8 @@ export const arpoon = `// "Arpoon"
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
// @by Felix Roos
useRNG('legacy')
samples('github:tidalcycles/dirt-samples')
n("[0,3] 2 [1,3] 2".fast(3).lastOf(4, fast(2))).clip(2)
+29
View File
@@ -9,11 +9,13 @@ import { getDrawContext } from '@strudel/draw';
import { evaluate, transpiler } from '@strudel/transpiler';
import {
getAudioContextCurrentTime,
renderPatternAudio,
webaudioOutput,
resetGlobalEffects,
resetLoadedSounds,
initAudioOnFirstClick,
resetDefaults,
initAudio,
} from '@strudel/webaudio';
import { setVersionDefaultsFrom } from './util.mjs';
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
@@ -37,6 +39,7 @@ import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser';
import { encode as base64urlencode } from 'base64url-universal';
import { debugAudiograph } from './audiograph';
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
@@ -146,6 +149,7 @@ export function useReplContext() {
bgFill: false,
});
window.strudelMirror = editor;
window.debugAudiograph = debugAudiograph;
// init settings
initCode().then(async (decoded) => {
@@ -224,6 +228,30 @@ export function useReplContext() {
const handleEvaluate = () => {
editorRef.current.evaluate();
};
const handleExport = async (begin, end, sampleRate, maxPolyphony, multiChannelOrbits, downloadName = undefined) => {
await editorRef.current.evaluate(false);
editorRef.current.repl.scheduler.stop();
await renderPatternAudio(
editorRef.current.repl.state.pattern,
editorRef.current.repl.scheduler.cps,
begin,
end,
sampleRate,
maxPolyphony,
multiChannelOrbits,
downloadName,
).finally(async () => {
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
await initAudio({
latestCode,
maxPolyphony,
audioDeviceName,
multiChannelOrbits,
});
editorRef.current.repl.scheduler.stop();
});
};
const handleShuffle = async () => {
const patternData = await getRandomTune();
const code = patternData.code;
@@ -252,6 +280,7 @@ export function useReplContext() {
handleShuffle,
handleShare,
handleEvaluate,
handleExport,
init,
error,
editorRef,