{
+ 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 = 'ExportDiagram';
+ 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();
+ }),
+ );
+};
diff --git a/website/src/repl/components/panel/ExportTab.jsx b/website/src/repl/components/panel/ExportTab.jsx
new file mode 100644
index 000000000..9671ff1a3
--- /dev/null
+++ b/website/src/repl/components/panel/ExportTab.jsx
@@ -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 (
+
+ );
+}
+
+function FormItem({ label, children, disabled }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+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 (
+ <>
+
+
+ {
+ 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 ?? ''}
+ />
+
+
+
+ {
+ 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 ?? ''}
+ />
+
+
+ {
+ 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 ?? ''}
+ />
+
+
+
+
+ {
+ 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 ?? ''}
+ />
+
+
+ {
+ 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 ?? ''}
+ />
+
+
+
+ {
+ const val = cbEvent.target.checked;
+ setMultiChannelOrbits(val);
+ }}
+ disabled={exporting}
+ value={multiChannelOrbits}
+ />
+
+
+
+
+ >
+ );
+}
diff --git a/website/src/repl/components/panel/Panel.jsx b/website/src/repl/components/panel/Panel.jsx
index b26c2edef..cfe90ae30 100644
--- a/website/src/repl/components/panel/Panel.jsx
+++ b/website/src/repl/components/panel/Panel.jsx
@@ -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 ;
case tabNames.reference:
return ;
+ case tabNames.export:
+ return ;
case tabNames.settings:
return ;
case tabNames.files:
diff --git a/website/src/repl/tunes.mjs b/website/src/repl/tunes.mjs
index f1c72b12d..697e83c9d 100644
--- a/website/src/repl/tunes.mjs
+++ b/website/src/repl/tunes.mjs
@@ -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,[~ ],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)
diff --git a/website/src/repl/useReplContext.jsx b/website/src/repl/useReplContext.jsx
index 8dcb6b754..0fbd89e38 100644
--- a/website/src/repl/useReplContext.jsx
+++ b/website/src/repl/useReplContext.jsx
@@ -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';
@@ -36,6 +38,7 @@ import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser';
+import { debugAudiograph } from './audiograph';
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
@@ -129,6 +132,7 @@ export function useReplContext() {
bgFill: false,
});
window.strudelMirror = editor;
+ window.debugAudiograph = debugAudiograph;
// init settings
initCode().then(async (decoded) => {
@@ -207,6 +211,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;
@@ -235,6 +263,7 @@ export function useReplContext() {
handleShuffle,
handleShare,
handleEvaluate,
+ handleExport,
init,
error,
editorRef,