mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-13 06:19:33 -04:00
Merge branch 'main' into space-shell/helix-keybindings
This commit is contained in:
@@ -293,9 +293,9 @@ export class StrudelMirror {
|
||||
console.warn('first frame could not be painted');
|
||||
}
|
||||
}
|
||||
async evaluate() {
|
||||
async evaluate(autostart = true) {
|
||||
this.flash();
|
||||
await this.repl.evaluate(this.code);
|
||||
await this.repl.evaluate(this.code, autostart);
|
||||
}
|
||||
async stop() {
|
||||
this.repl.scheduler.stop();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import { Prec, EditorState } from '@codemirror/state';
|
||||
import { keymap, ViewPlugin } from '@codemirror/view';
|
||||
// import { searchKeymap } from '@codemirror/search';
|
||||
import { emacs } from '@replit/codemirror-emacs';
|
||||
@@ -135,5 +135,9 @@ const keymaps = {
|
||||
|
||||
export function keybindings(name) {
|
||||
const active = keymaps[name];
|
||||
return [active ? Prec.high(active()) : []];
|
||||
const extensions = active ? [Prec.high(active())] : [];
|
||||
if (name === 'vim') {
|
||||
extensions.push(EditorState.allowMultipleSelections.of(true));
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ export const setDefaultAudioContext = () => {
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export const setAudioContext = (context) => {
|
||||
audioContext = context;
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export const getAudioContext = () => {
|
||||
if (!audioContext) {
|
||||
return setDefaultAudioContext();
|
||||
|
||||
@@ -25,7 +25,7 @@ if (typeof DelayNode !== 'undefined') {
|
||||
}
|
||||
}
|
||||
|
||||
AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
|
||||
BaseAudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
|
||||
return new FeedbackDelayNode(this, wet, time, feedback);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const getParamADSR = (
|
||||
decay,
|
||||
sustain,
|
||||
release,
|
||||
// min = value at start of attack, max = value at end of attack; it is possible that max < min
|
||||
min,
|
||||
max,
|
||||
begin,
|
||||
@@ -59,17 +60,15 @@ export const getParamADSR = (
|
||||
max = max === 0 ? 0.001 : max;
|
||||
}
|
||||
const range = max - min;
|
||||
const peak = max;
|
||||
const sustainVal = min + sustain * range;
|
||||
const duration = end - begin;
|
||||
|
||||
const envValAtTime = (time) => {
|
||||
let val;
|
||||
if (attack > time) {
|
||||
let slope = getSlope(min, peak, 0, attack);
|
||||
val = time * slope + (min > peak ? min : 0);
|
||||
val = time * getSlope(min, max, 0, attack) + min;
|
||||
} else {
|
||||
val = (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak;
|
||||
val = (time - attack) * getSlope(max, sustainVal, 0, decay) + max;
|
||||
}
|
||||
if (curve === 'exponential') {
|
||||
val = val || 0.001;
|
||||
|
||||
@@ -2,7 +2,7 @@ import reverbGen from './reverbGen.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
|
||||
if (typeof AudioContext !== 'undefined') {
|
||||
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
||||
BaseAudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
||||
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
|
||||
const newLength = buffer.sampleRate * duration;
|
||||
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
|
||||
@@ -23,7 +23,7 @@ if (typeof AudioContext !== 'undefined') {
|
||||
return newBuffer;
|
||||
};
|
||||
|
||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||
BaseAudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||
const convolver = this.createConvolver();
|
||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
|
||||
convolver.duration = d;
|
||||
|
||||
@@ -22,13 +22,14 @@ import {
|
||||
import { map } from 'nanostores';
|
||||
import { logger } from './logger.mjs';
|
||||
import { loadBuffer } from './sampler.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { getAudioContext, setAudioContext } from './audioContext.mjs';
|
||||
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
||||
import { resetSeenKeys } from './wavetable.mjs';
|
||||
|
||||
export const DEFAULT_MAX_POLYPHONY = 128;
|
||||
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
|
||||
|
||||
let maxPolyphony = DEFAULT_MAX_POLYPHONY;
|
||||
export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
|
||||
|
||||
/**
|
||||
* Set the max polyphony. If notes are ringing out via `release` then they will
|
||||
@@ -45,7 +46,7 @@ export function setMaxPolyphony(polyphony) {
|
||||
maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY;
|
||||
}
|
||||
|
||||
let multiChannelOrbits = false;
|
||||
export let multiChannelOrbits = false;
|
||||
export function setMultiChannelOrbits(bool) {
|
||||
multiChannelOrbits = bool == true;
|
||||
}
|
||||
@@ -234,11 +235,13 @@ export function registerWorklet(url) {
|
||||
}
|
||||
|
||||
let workletsLoading;
|
||||
function loadWorklets() {
|
||||
export function loadWorklets() {
|
||||
if (!workletsLoading) {
|
||||
const audioCtx = getAudioContext();
|
||||
const allWorkletURLs = externalWorklets.concat([workletsUrl]);
|
||||
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
|
||||
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then(
|
||||
() => (workletsLoading = undefined),
|
||||
);
|
||||
}
|
||||
|
||||
return workletsLoading;
|
||||
@@ -255,6 +258,7 @@ export async function initAudio(options = {}) {
|
||||
|
||||
setMaxPolyphony(maxPolyphony);
|
||||
setMultiChannelOrbits(multiChannelOrbits);
|
||||
resetSeenKeys();
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -276,8 +280,9 @@ export async function initAudio(options = {}) {
|
||||
logger('[superdough] failed to set audio interface', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
await audioCtx.resume();
|
||||
if ((!audioCtx) instanceof OfflineAudioContext) {
|
||||
await audioCtx.resume();
|
||||
}
|
||||
if (disableWorklets) {
|
||||
logger('[superdough]: AudioWorklets disabled with disableWorklets');
|
||||
return;
|
||||
@@ -311,6 +316,12 @@ export function getSuperdoughAudioController() {
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
export function setSuperdoughAudioController(newController) {
|
||||
controller = newController;
|
||||
return controller;
|
||||
}
|
||||
|
||||
export function connectToDestination(input, channels) {
|
||||
const controller = getSuperdoughAudioController();
|
||||
controller.output.connectToDestination(input, channels);
|
||||
@@ -348,7 +359,7 @@ export let analysers = {},
|
||||
analysersData = {};
|
||||
|
||||
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
|
||||
if (!analysers[id]) {
|
||||
if (!analysers[id] || analysers[id].audioContext != getAudioContext()) {
|
||||
// make sure this doesn't happen too often as it piles up garbage
|
||||
const analyserNode = getAudioContext().createAnalyser();
|
||||
analyserNode.fftSize = fftSize;
|
||||
@@ -410,7 +421,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
// duration is passed as value too..
|
||||
value.duration = hapDuration;
|
||||
// calculate absolute time
|
||||
|
||||
if (t < ac.currentTime) {
|
||||
console.warn(
|
||||
`[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
|
||||
@@ -782,7 +792,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
}
|
||||
|
||||
// analyser
|
||||
if (analyze) {
|
||||
if (analyze && !(ac instanceof OfflineAudioContext)) {
|
||||
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
|
||||
const analyserSend = effectSend(post, analyserNode, 1);
|
||||
audioNodes.push(analyserSend);
|
||||
|
||||
@@ -75,7 +75,7 @@ if (typeof GainNode !== 'undefined') {
|
||||
}
|
||||
}
|
||||
|
||||
AudioContext.prototype.createVowelFilter = function (letter) {
|
||||
BaseAudioContext.prototype.createVowelFilter = function (letter) {
|
||||
return new VowelNode(this, letter);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ export const Warpmode = Object.freeze({
|
||||
});
|
||||
|
||||
const seenKeys = new Set();
|
||||
|
||||
export function resetSeenKeys() {
|
||||
seenKeys.clear();
|
||||
}
|
||||
|
||||
async function getPayload(url, label, frameLen = 2048) {
|
||||
const key = `${url},${frameLen}`;
|
||||
if (!seenKeys.has(key)) {
|
||||
|
||||
@@ -5,10 +5,21 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import * as strudel from '@strudel/core';
|
||||
import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough';
|
||||
import {
|
||||
superdough,
|
||||
getAudioContext,
|
||||
setLogger,
|
||||
doughTrigger,
|
||||
registerWorklet,
|
||||
setAudioContext,
|
||||
initAudio,
|
||||
setSuperdoughAudioController,
|
||||
resetGlobalEffects,
|
||||
errorLogger,
|
||||
} from 'superdough';
|
||||
import './supradough.mjs';
|
||||
import { workletUrl } from 'supradough';
|
||||
|
||||
import { SuperdoughAudioController } from 'superdough/superdoughoutput.mjs';
|
||||
registerWorklet(workletUrl);
|
||||
|
||||
const { Pattern, logger, repl } = strudel;
|
||||
@@ -26,6 +37,71 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
|
||||
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
|
||||
};
|
||||
|
||||
export async function renderPatternAudio(
|
||||
pattern,
|
||||
cps,
|
||||
begin,
|
||||
end,
|
||||
sampleRate,
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
downloadName = undefined,
|
||||
) {
|
||||
let audioContext = getAudioContext();
|
||||
await audioContext.close();
|
||||
audioContext = new OfflineAudioContext(2, ((end - begin) / cps) * sampleRate, sampleRate);
|
||||
setAudioContext(audioContext);
|
||||
setSuperdoughAudioController(new SuperdoughAudioController(audioContext));
|
||||
await initAudio({
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
});
|
||||
logger('[webaudio] preloading');
|
||||
|
||||
// Calling superdough(...) in ascending onset time order is important
|
||||
// for controls that depend on the audio graph state like `cut`
|
||||
let haps = pattern
|
||||
.queryArc(begin, end, { _cps: cps })
|
||||
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
|
||||
for (const hap of haps) {
|
||||
if (hap.hasOnset()) {
|
||||
try {
|
||||
await superdough(
|
||||
hap2value(hap),
|
||||
(hap.whole.begin.valueOf() - begin) / cps,
|
||||
hap.duration / cps,
|
||||
cps,
|
||||
(hap.whole?.begin.valueOf() - begin) / cps,
|
||||
);
|
||||
} catch (err) {
|
||||
errorLogger(err, 'webaudio');
|
||||
}
|
||||
}
|
||||
}
|
||||
logger('[webaudio] start rendering');
|
||||
|
||||
return audioContext
|
||||
.startRendering()
|
||||
.then((renderedBuffer) => {
|
||||
const wavBuffer = audioBufferToWav(renderedBuffer);
|
||||
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`;
|
||||
a.download = `${downloadName}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.finally(async () => {
|
||||
setAudioContext(null);
|
||||
setSuperdoughAudioController(null);
|
||||
resetGlobalEffects();
|
||||
});
|
||||
}
|
||||
|
||||
export function webaudioRepl(options = {}) {
|
||||
options = {
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
@@ -38,3 +114,98 @@ export function webaudioRepl(options = {}) {
|
||||
Pattern.prototype.dough = function () {
|
||||
return this.onTrigger(doughTrigger, 1);
|
||||
};
|
||||
|
||||
function audioBufferToWav(buffer, opt) {
|
||||
opt = opt || {};
|
||||
|
||||
var numChannels = buffer.numberOfChannels;
|
||||
var sampleRate = buffer.sampleRate;
|
||||
var format = opt.float32 ? 3 : 1;
|
||||
var bitDepth = format === 3 ? 32 : 16;
|
||||
|
||||
var result;
|
||||
if (numChannels === 2) {
|
||||
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
|
||||
} else {
|
||||
result = buffer.getChannelData(0);
|
||||
}
|
||||
|
||||
return encodeWAV(result, format, sampleRate, numChannels, bitDepth);
|
||||
}
|
||||
|
||||
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
|
||||
var bytesPerSample = bitDepth / 8;
|
||||
var blockAlign = numChannels * bytesPerSample;
|
||||
|
||||
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
|
||||
var view = new DataView(buffer);
|
||||
|
||||
/* RIFF identifier */
|
||||
writeString(view, 0, 'RIFF');
|
||||
/* RIFF chunk length */
|
||||
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
|
||||
/* RIFF type */
|
||||
writeString(view, 8, 'WAVE');
|
||||
/* format chunk identifier */
|
||||
writeString(view, 12, 'fmt ');
|
||||
/* format chunk length */
|
||||
view.setUint32(16, 16, true);
|
||||
/* sample format (raw) */
|
||||
view.setUint16(20, format, true);
|
||||
/* channel count */
|
||||
view.setUint16(22, numChannels, true);
|
||||
/* sample rate */
|
||||
view.setUint32(24, sampleRate, true);
|
||||
/* byte rate (sample rate * block align) */
|
||||
view.setUint32(28, sampleRate * blockAlign, true);
|
||||
/* block align (channel count * bytes per sample) */
|
||||
view.setUint16(32, blockAlign, true);
|
||||
/* bits per sample */
|
||||
view.setUint16(34, bitDepth, true);
|
||||
/* data chunk identifier */
|
||||
writeString(view, 36, 'data');
|
||||
/* data chunk length */
|
||||
view.setUint32(40, samples.length * bytesPerSample, true);
|
||||
if (format === 1) {
|
||||
// Raw PCM
|
||||
floatTo16BitPCM(view, 44, samples);
|
||||
} else {
|
||||
writeFloat32(view, 44, samples);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function interleave(inputL, inputR) {
|
||||
var length = inputL.length + inputR.length;
|
||||
var result = new Float32Array(length);
|
||||
|
||||
var index = 0;
|
||||
var inputIndex = 0;
|
||||
|
||||
while (index < length) {
|
||||
result[index++] = inputL[inputIndex];
|
||||
result[index++] = inputR[inputIndex];
|
||||
inputIndex++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeFloat32(output, offset, input) {
|
||||
for (var i = 0; i < input.length; i++, offset += 4) {
|
||||
output.setFloat32(offset, input[i], true);
|
||||
}
|
||||
}
|
||||
|
||||
function floatTo16BitPCM(output, offset, input) {
|
||||
for (var i = 0; i < input.length; i++, offset += 2) {
|
||||
var s = Math.max(-1, Math.min(1, input[i]));
|
||||
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
}
|
||||
|
||||
function writeString(view, offset, string) {
|
||||
for (var i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(' ', ' ') + ']\n';
|
||||
|
||||
let { svg } = await mermaid.render('strudelSvgId', gd);
|
||||
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
|
||||
svg = svg.replaceAll('&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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user