Merge pull request #255 from tidalcycles/repl-refactoring

Repl refactoring
This commit is contained in:
Felix Roos 2022-11-13 20:20:32 +01:00 committed by GitHub
commit 4bc2f64b24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 1662 additions and 1538 deletions

View File

@ -1,7 +1,7 @@
{
"source": {
"includePattern": ".+\\.(js(doc|x)?|mjs)$",
"excludePattern": "node_modules|shift-parser|shift-reducer|shift-traverser"
"excludePattern": "node_modules|shift-parser|shift-reducer|shift-traverser|dist"
},
"plugins": ["plugins/markdown"],
"opts": {

41
package-lock.json generated
View File

@ -12495,10 +12495,10 @@
},
"packages/midi": {
"name": "@strudel.cycles/midi",
"version": "0.3.2",
"version": "0.3.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel.cycles/tone": "^0.3.2",
"@strudel.cycles/tone": "^0.3.3",
"tone": "^14.7.77",
"webmidi": "^3.0.21"
}
@ -12519,12 +12519,12 @@
},
"packages/mini": {
"name": "@strudel.cycles/mini",
"version": "0.3.2",
"version": "0.3.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel.cycles/core": "^0.3.2",
"@strudel.cycles/eval": "^0.3.2",
"@strudel.cycles/tone": "^0.3.2"
"@strudel.cycles/tone": "^0.3.3"
},
"devDependencies": {
"peggy": "^2.0.1"
@ -12540,13 +12540,14 @@
},
"packages/react": {
"name": "@strudel.cycles/react",
"version": "0.3.2",
"version": "0.3.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@codemirror/lang-javascript": "^6.1.1",
"@strudel.cycles/core": "^0.3.2",
"@strudel.cycles/eval": "^0.3.2",
"@strudel.cycles/tone": "^0.3.2",
"@strudel.cycles/tone": "^0.3.3",
"@strudel.cycles/transpiler": "^0.3.2",
"@strudel.cycles/webaudio": "^0.3.3",
"@uiw/codemirror-themes": "^4.12.4",
"@uiw/react-codemirror": "^4.12.4",
"react-hook-inview": "^4.5.0"
@ -12621,11 +12622,11 @@
},
"packages/soundfonts": {
"name": "@strudel.cycles/soundfonts",
"version": "0.3.2",
"version": "0.3.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel.cycles/core": "^0.3.2",
"@strudel.cycles/webaudio": "^0.3.2",
"@strudel.cycles/webaudio": "^0.3.3",
"sfumato": "^0.1.2",
"soundfont2": "^0.4.0"
},
@ -12653,7 +12654,7 @@
},
"packages/tonal": {
"name": "@strudel.cycles/tonal",
"version": "0.3.2",
"version": "0.3.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel.cycles/core": "^0.3.2",
@ -12678,7 +12679,7 @@
},
"packages/tone": {
"name": "@strudel.cycles/tone",
"version": "0.3.2",
"version": "0.3.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel.cycles/core": "^0.3.2",
@ -12709,7 +12710,7 @@
},
"packages/webaudio": {
"name": "@strudel.cycles/webaudio",
"version": "0.3.2",
"version": "0.3.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel.cycles/core": "^0.3.2"
@ -14427,7 +14428,7 @@
"@strudel.cycles/midi": {
"version": "file:packages/midi",
"requires": {
"@strudel.cycles/tone": "^0.3.2",
"@strudel.cycles/tone": "^0.3.3",
"tone": "^14.7.77",
"webmidi": "^3.0.21"
},
@ -14448,7 +14449,7 @@
"requires": {
"@strudel.cycles/core": "^0.3.2",
"@strudel.cycles/eval": "^0.3.2",
"@strudel.cycles/tone": "^0.3.2",
"@strudel.cycles/tone": "^0.3.3",
"peggy": "^2.0.1"
}
},
@ -14463,8 +14464,9 @@
"requires": {
"@codemirror/lang-javascript": "^6.1.1",
"@strudel.cycles/core": "^0.3.2",
"@strudel.cycles/eval": "^0.3.2",
"@strudel.cycles/tone": "^0.3.2",
"@strudel.cycles/tone": "^0.3.3",
"@strudel.cycles/transpiler": "^0.3.2",
"@strudel.cycles/webaudio": "^0.3.3",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2",
"@uiw/codemirror-themes": "^4.12.4",
@ -14520,7 +14522,7 @@
"version": "file:packages/soundfonts",
"requires": {
"@strudel.cycles/core": "^0.3.2",
"@strudel.cycles/webaudio": "^0.3.2",
"@strudel.cycles/webaudio": "^0.3.3",
"node-fetch": "^3.2.6",
"sfumato": "^0.1.2",
"soundfont2": "^0.4.0"
@ -20343,8 +20345,9 @@
"requires": {
"@codemirror/lang-javascript": "^6.1.1",
"@strudel.cycles/core": "^0.3.2",
"@strudel.cycles/eval": "^0.3.2",
"@strudel.cycles/tone": "^0.3.2",
"@strudel.cycles/tone": "^0.3.3",
"@strudel.cycles/transpiler": "^0.3.2",
"@strudel.cycles/webaudio": "^0.3.3",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2",
"@uiw/codemirror-themes": "^4.12.4",

View File

@ -5,6 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import createClock from './zyklus.mjs';
import { logger } from './logger.mjs';
export class Cyclist {
worker;
@ -13,8 +14,10 @@ export class Cyclist {
cps = 1; // TODO
getTime;
phase = 0;
constructor({ interval, onTrigger, onError, getTime, latency = 0.1 }) {
constructor({ interval, onTrigger, onToggle, onError, getTime, latency = 0.1 }) {
this.getTime = getTime;
this.onToggle = onToggle;
this.latency = latency;
const round = (x) => Math.round(x * 1000) / 1000;
this.clock = createClock(
getTime,
@ -28,9 +31,7 @@ export class Cyclist {
const time = getTime();
try {
const haps = this.pattern.queryArc(begin, end); // get Haps
// console.log('haps', haps.map((hap) => hap.value.n).join(' '));
haps.forEach((hap) => {
// console.log('hap', hap.value.n, hap.part.begin);
if (hap.part.begin.equals(hap.whole.begin)) {
const deadline = hap.whole.begin + this.origin - time + latency;
const duration = hap.duration * 1;
@ -38,7 +39,7 @@ export class Cyclist {
}
});
} catch (e) {
console.warn('scheduler error', e);
logger(`[cyclist] error: ${e.message}`);
onError?.(e);
}
}, // called slightly before each cycle
@ -46,24 +47,29 @@ export class Cyclist {
);
}
getPhase() {
return this.phase;
return this.getTime() - this.origin - this.latency;
}
setStarted(v) {
this.started = v;
this.onToggle?.(v);
}
start() {
if (!this.pattern) {
throw new Error('Scheduler: no pattern set! call .setPattern first.');
}
logger('[cyclist] start');
this.clock.start();
this.started = true;
this.setStarted(true);
}
pause() {
this.clock.stop();
delete this.origin;
this.started = false;
logger('[cyclist] pause');
this.clock.pause();
this.setStarted(false);
}
stop() {
delete this.origin;
logger('[cyclist] stop');
this.clock.stop();
this.started = false;
this.setStarted(false);
}
setPattern(pat, autostart = false) {
this.pattern = pat;

View File

@ -4,8 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
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/>.
*/
import { Tone } from './tone.mjs';
import { Pattern } from '@strudel.cycles/core';
import { Pattern, getTime } from './index.mjs';
export const getDrawContext = (id = 'test-canvas') => {
let canvas = document.querySelector('#' + id);
@ -28,7 +27,7 @@ Pattern.prototype.draw = function (callback, { from, to, onQuery }) {
let cycle,
events = [];
const animate = (time) => {
const t = Tone.getTransport().seconds;
const t = getTime();
if (from !== undefined && to !== undefined) {
const currentCycle = Math.floor(t);
if (cycle !== currentCycle) {
@ -50,9 +49,9 @@ Pattern.prototype.draw = function (callback, { from, to, onQuery }) {
return this;
};
export const cleanupDraw = () => {
export const cleanupDraw = (clearScreen = true) => {
const ctx = getDrawContext();
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
clearScreen && ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
if (window.strudelAnimation) {
cancelAnimationFrame(window.strudelAnimation);
}

View File

@ -4,9 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
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/>.
*/
import * as strudel from '@strudel.cycles/core';
const { isPattern, Pattern } = strudel;
import { isPattern, Pattern } from './index.mjs';
let scoped = false;
export const evalScope = async (...args) => {

View File

@ -55,7 +55,8 @@ Fraction.prototype.min = function (other) {
return this.lt(other) ? this : other;
};
Fraction.prototype.show = function () {
Fraction.prototype.show = function (/* excludeWhole = false */) {
// return this.toFraction(excludeWhole);
return this.s * this.n + '/' + this.d;
};

View File

@ -85,9 +85,13 @@ export class Hap {
);
}
showWhole() {
showWhole(compact = false) {
return `${this.whole == undefined ? '~' : this.whole.show()}: ${
typeof this.value === 'object' ? JSON.stringify(this.value) : this.value
typeof this.value === 'object'
? compact
? JSON.stringify(this.value).slice(1, -1).replaceAll('"', '').replaceAll(',', ' ')
: JSON.stringify(this.value)
: this.value
}`;
}

View File

@ -7,6 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
import controls from './controls.mjs';
export * from './euclid.mjs';
import Fraction from './fraction.mjs';
import { logger } from './logger.mjs';
export { Fraction, controls };
export * from './hap.mjs';
export * from './pattern.mjs';
@ -17,15 +18,17 @@ export * from './util.mjs';
export * from './speak.mjs';
export * from './evaluate.mjs';
export * from './repl.mjs';
export * from './logger.mjs';
export * from './time.mjs';
export * from './draw.mjs';
export * from './pianoroll.mjs';
export * from './ui.mjs';
export { default as drawLine } from './drawLine.mjs';
export { default as gist } from './gist.js';
// below won't work with runtime.mjs (json import fails)
/* import * as p from './package.json';
export const version = p.version; */
console.log(
'%c // 🌀 @strudel.cycles/core loaded 🌀', // keep "//" for runnable snapshot source..
'background-color: black;color:white;padding:4px;border-radius:15px',
);
logger('🌀 @strudel.cycles/core loaded 🌀');
if (globalThis._strudelLoaded) {
console.warn(
`@strudel.cycles/core was loaded more than once...

18
packages/core/logger.mjs Normal file
View File

@ -0,0 +1,18 @@
export const logKey = 'strudel.log';
export function logger(message, type, data = {}) {
console.log(`%c${message}`, 'background-color: black;color:white;border-radius:15px');
if (typeof CustomEvent !== 'undefined') {
document.dispatchEvent(
new CustomEvent(logKey, {
detail: {
message,
type,
data,
},
}),
);
}
}
logger.key = logKey;

View File

@ -12,6 +12,7 @@ import { unionWithObj } from './value.mjs';
import { compose, removeUndefineds, flatten, id, listRange, curry, mod, numeralArgs, parseNumeral } from './util.mjs';
import drawLine from './drawLine.mjs';
import { logger } from './logger.mjs';
let stringParser;
// parser is expected to turn a string into a pattern
@ -1328,22 +1329,25 @@ export class Pattern {
.unit('c')
.slow(factor);
}
onTrigger(onTrigger) {
return this._withHap((hap) => hap.setContext({ ...hap.context, onTrigger }));
}
log(func = id) {
onTrigger(onTrigger, dominant = true) {
return this._withHap((hap) =>
hap.setContext({
...hap.context,
onTrigger: (...args) => {
if (hap.context.onTrigger) {
if (!dominant && hap.context.onTrigger) {
hap.context.onTrigger(...args);
}
console.log(func(...args));
onTrigger(...args);
},
// we need this to know later if the default trigger should still fire
dominantTrigger: dominant,
}),
);
}
log(func = (_, hap) => `[hap] ${hap.showWhole(true)}`) {
return this.onTrigger((...args) => logger(func(...args)), false);
}
logValues(func = id) {
return this.log((_, hap) => func(hap.value));
}

View File

@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
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/>.
*/
import { Pattern } from '@strudel.cycles/core';
import { Pattern } from './index.mjs';
const scale = (normalized, min, max) => normalized * (max - min) + min;
const getValue = (e) => {

View File

@ -1,23 +1,59 @@
import { Cyclist } from './cyclist.mjs';
import { evaluate as _evaluate } from './evaluate.mjs';
import { logger } from './logger.mjs';
import { setTime } from './time.mjs';
export function repl({ interval, defaultOutput, onSchedulerError, onEvalError, onEval, getTime, transpiler }) {
const scheduler = new Cyclist({ interval, onTrigger: defaultOutput, onError: onSchedulerError, getTime });
const evaluate = async (code) => {
export function repl({
interval,
defaultOutput,
onSchedulerError,
onEvalError,
beforeEval,
afterEval,
getTime,
transpiler,
onToggle,
}) {
const scheduler = new Cyclist({
interval,
onTrigger: async (hap, deadline, duration) => {
try {
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
await defaultOutput(hap, deadline, duration);
}
if (hap.context.onTrigger) {
const cps = 1;
// call signature of output / onTrigger is different...
await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps);
}
} catch (err) {
logger(`[cyclist] error: ${err.message}`, 'error');
}
},
onError: onSchedulerError,
getTime,
onToggle,
});
setTime(() => scheduler.getPhase()); // TODO: refactor?
const evaluate = async (code, autostart = true) => {
if (!code) {
throw new Error('no code to evaluate');
}
try {
beforeEval({ code });
const { pattern } = await _evaluate(code, transpiler);
scheduler.setPattern(pattern, true);
onEval?.({
pattern,
code,
});
logger(`[eval] code updated`);
scheduler.setPattern(pattern, autostart);
afterEval({ code, pattern });
return pattern;
} catch (err) {
console.warn(`eval error: ${err.message}`);
// console.warn(`[repl] eval error: ${err.message}`);
logger(`[eval] error: ${err.message}`, 'error');
onEvalError?.(err);
}
};
return { scheduler, evaluate };
const stop = () => scheduler.stop();
const start = () => scheduler.start();
const pause = () => scheduler.pause();
return { scheduler, evaluate, start, stop, pause };
}

View File

@ -32,11 +32,8 @@ function speak(words, lang, voice) {
}
Pattern.prototype._speak = function (lang, voice) {
return this._withHap((hap) => {
const onTrigger = (time, hap) => {
speak(hap.value, lang, voice);
};
return hap.setContext({ ...hap.context, onTrigger });
return this.onTrigger((_, hap) => {
speak(hap.value, lang, voice);
});
};

11
packages/core/time.mjs Normal file
View File

@ -0,0 +1,11 @@
let time;
export function getTime() {
if (!time) {
throw new Error('no time set! use setTime to define a time source');
}
return time();
}
export function setTime(func) {
time = func;
}

View File

@ -4,19 +4,14 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
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/>.
*/
import { Tone } from './tone.mjs';
export const hideHeader = () => {
document.getElementById('header').style = 'display:none';
};
import { getTime } from './time.mjs';
function frame(callback) {
if (window.strudelAnimation) {
cancelAnimationFrame(window.strudelAnimation);
}
const animate = (animationTime) => {
const toneTime = Tone.getTransport().seconds;
callback(animationTime, toneTime);
callback(animationTime, getTime());
window.strudelAnimation = requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
@ -51,6 +46,7 @@ export const cleanupUi = () => {
const container = document.getElementById('code');
if (container) {
container.style = '';
container.className = 'grow relative'; // has to match App.tsx
// TODO: find a way to remove that duplication..
container.className = 'grow flex text-gray-100 relative overflow-auto cursor-text pb-0'; // has to match App.tsx
}
};

View File

@ -55,9 +55,13 @@ export const midi2note = (n) => {
export const mod = (n, m) => ((n % m) + m) % m;
export const getPlayableNoteValue = (hap) => {
let { value: note, context } = hap;
let { value, context } = hap;
let note = value;
if (typeof note === 'object' && !Array.isArray(note)) {
note = note.note || note.n || note.value;
if (note === undefined) {
throw new Error(`cannot find a playable note for ${JSON.stringify(value)}`);
}
}
// if value is number => interpret as midi number as long as its not marked as frequency
if (typeof note === 'number' && context.type !== 'frequency') {

View File

@ -31,10 +31,11 @@ function createClock(
};
let intervalID;
const start = () => {
clear(); // just in case start was called more than once
onTick();
intervalID = setInterval(onTick, interval * 1000);
};
const clear = () => clearInterval(intervalID);
const clear = () => intervalID !== undefined && clearInterval(intervalID);
const pause = () => clear();
const stop = () => {
tick = 0;

View File

@ -4,14 +4,19 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
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/>.
*/
import { isNote } from 'tone';
import * as _WebMidi from 'webmidi';
import { Pattern, isPattern } from '@strudel.cycles/core';
import { Tone } from '@strudel.cycles/tone';
import { Pattern, isPattern, isNote, getPlayableNoteValue, logger } from '@strudel.cycles/core';
import { getAudioContext } from '@strudel.cycles/webaudio';
// if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi;
export function enableWebMidi() {
export function enableWebMidi(options = {}) {
const { onReady, onConnected, onDisconnected } = options;
if (typeof navigator.requestMIDIAccess !== 'function') {
throw new Error('Your Browser does not support WebMIDI.');
}
return new Promise((resolve, reject) => {
if (WebMidi.enabled) {
// if already enabled, just resolve WebMidi
@ -22,6 +27,14 @@ export function enableWebMidi() {
if (err) {
reject(err);
}
WebMidi.addListener('connected', (e) => {
onConnected?.(WebMidi);
});
// Reacting when a device becomes unavailable
WebMidi.addListener('disconnected', (e) => {
onDisconnected?.(WebMidi, e);
});
onReady?.(WebMidi);
resolve(WebMidi);
});
});
@ -30,7 +43,21 @@ export function enableWebMidi() {
const outputByName = (name) => WebMidi.getOutputByName(name);
// Pattern.prototype.midi = function (output: string | number, channel = 1) {
Pattern.prototype.midi = function (output, channel = 1) {
Pattern.prototype.midi = async function (output, channel = 1) {
await enableWebMidi({
onConnected: ({ outputs }) =>
logger(`Midi device connected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`),
onDisconnected: ({ outputs }) =>
logger(`Midi device disconnected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`),
onReady: ({ outputs }) => {
const chosenOutput = output ?? outputs[0];
const otherOutputs = outputs
.filter((o) => o.name !== chosenOutput.name)
.map((o) => `'${o.name}'`)
.join(' | ');
logger(`Midi connected! Using "${chosenOutput.name}". ${otherOutputs ? `Also available: ${otherOutputs}` : ''}`);
},
});
if (isPattern(output?.constructor?.name)) {
throw new Error(
`.midi does not accept Pattern input. Make sure to pass device name with single quotes. Example: .midi('${
@ -38,46 +65,42 @@ Pattern.prototype.midi = function (output, channel = 1) {
}')`,
);
}
return this._withHap((hap) => {
// const onTrigger = (time: number, hap: any) => {
const onTrigger = (time, hap) => {
let note = hap.value;
const velocity = hap.context?.velocity ?? 0.9;
if (!isNote(note)) {
throw new Error('not a note: ' + note);
}
if (!WebMidi.enabled) {
throw new Error(`🎹 WebMidi is not enabled. Supported Browsers: https://caniuse.com/?search=webmidi`);
}
if (!WebMidi.outputs.length) {
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
}
let device;
if (typeof output === 'number') {
device = WebMidi.outputs[output];
} else if (typeof output === 'string') {
device = outputByName(output);
} else {
device = WebMidi.outputs[0];
}
if (!device) {
throw new Error(
`🔌 MIDI device '${output ? output : ''}' not found. Use one of ${WebMidi.outputs
.map((o) => `'${o.name}'`)
.join(' | ')}`,
);
}
// console.log('midi', value, output);
const timingOffset = WebMidi.time - Tone.getContext().currentTime * 1000;
time = time * 1000 + timingOffset;
// const inMs = '+' + (time - Tone.getContext().currentTime) * 1000;
// await enableWebMidi()
device.playNote(note, channel, {
time,
duration: hap.duration.valueOf() * 1000 - 5,
velocity,
});
};
return hap.setContext({ ...hap.context, onTrigger });
return this.onTrigger((time, hap) => {
let note = getPlayableNoteValue(hap);
const velocity = hap.context?.velocity ?? 0.9;
if (!isNote(note)) {
throw new Error('not a note: ' + note);
}
if (!WebMidi.enabled) {
throw new Error(`🎹 WebMidi is not enabled. Supported Browsers: https://caniuse.com/?search=webmidi`);
}
if (!WebMidi.outputs.length) {
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
}
let device;
if (typeof output === 'number') {
device = WebMidi.outputs[output];
} else if (typeof output === 'string') {
device = outputByName(output);
} else {
device = WebMidi.outputs[0];
}
if (!device) {
throw new Error(
`🔌 MIDI device '${output ? output : ''}' not found. Use one of ${WebMidi.outputs
.map((o) => `'${o.name}'`)
.join(' | ')}`,
);
}
// console.log('midi', value, output);
const timingOffset = WebMidi.time - getAudioContext().currentTime * 1000;
time = time * 1000 + timingOffset;
// const inMs = '+' + (time - Tone.getContext().currentTime) * 1000;
// await enableWebMidi()
device.playNote(note, channel, {
time,
duration: hap.duration.valueOf() * 1000 - 5,
attack: velocity,
});
});
};

View File

@ -32,7 +32,7 @@ function peg$padEnd(str, targetLength, padString) {
}
peg$SyntaxError.prototype.format = function(sources) {
var str = "Error: " + this.message;
var str = "peg error: " + this.message;
if (this.location) {
var src = null;
var k;

View File

@ -5,10 +5,34 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import OSC from 'osc-js';
import { parseNumeral, Pattern } from '@strudel.cycles/core';
import { logger, parseNumeral, Pattern } from '@strudel.cycles/core';
let connection; // Promise<OSC>
function connect() {
if (!connection) {
// make sure this runs only once
connection = new Promise((resolve, reject) => {
const osc = new OSC();
osc.open();
osc.on('open', () => {
const url = osc.options?.plugin?.socket?.url;
logger(`[osc] connected${url ? ` to ${url}` : ''}`);
resolve(osc);
});
osc.on('close', () => {
connection = undefined; // allows new connection afterwards
console.log('[osc] disconnected');
reject('OSC connection closed');
});
osc.on('error', (err) => reject(err));
}).catch((err) => {
connection = undefined;
throw new Error('Could not connect to OSC server. Is it running?');
});
}
return connection;
}
const comm = new OSC();
comm.open();
const latency = 0.1;
let startedAt = -1;
@ -20,28 +44,25 @@ let startedAt = -1;
* @memberof Pattern
* @returns Pattern
*/
Pattern.prototype.osc = function () {
return this._withHap((hap) => {
const onTrigger = (time, hap, currentTime, cps) => {
const cycle = hap.wholeOrPart().begin.valueOf();
const delta = hap.duration.valueOf();
// time should be audio time of onset
// currentTime should be current time of audio context (slightly before time)
if (startedAt < 0) {
startedAt = Date.now() - currentTime * 1000;
}
const controls = Object.assign({}, { cps, cycle, delta }, hap.value);
// make sure n and note are numbers
controls.n && (controls.n = parseNumeral(controls.n));
controls.note && (controls.note = parseNumeral(controls.note));
const keyvals = Object.entries(controls).flat();
const ts = Math.floor(startedAt + (time + latency) * 1000);
const message = new OSC.Message('/dirt/play', ...keyvals);
const bundle = new OSC.Bundle([message], ts);
bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60
comm.send(bundle);
};
return hap.setContext({ ...hap.context, onTrigger });
Pattern.prototype.osc = async function () {
const osc = await connect();
return this.onTrigger((time, hap, currentTime, cps = 1) => {
const cycle = hap.wholeOrPart().begin.valueOf();
const delta = hap.duration.valueOf();
// time should be audio time of onset
// currentTime should be current time of audio context (slightly before time)
if (startedAt < 0) {
startedAt = Date.now() - currentTime * 1000;
}
const controls = Object.assign({}, { cps, cycle, delta }, hap.value);
// make sure n and note are numbers
controls.n && (controls.n = parseNumeral(controls.n));
controls.note && (controls.note = parseNumeral(controls.note));
const keyvals = Object.entries(controls).flat();
const ts = Math.floor(startedAt + (time + latency) * 1000);
const message = new OSC.Message('/dirt/play', ...keyvals);
const bundle = new OSC.Bundle([message], ts);
bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60
osc.send(bundle);
});
};

File diff suppressed because one or more lines are too long

View File

@ -1,17 +1,15 @@
import y, { useCallback as T, useState as w, useEffect as q, useMemo as R, useRef as I, useLayoutEffect as j } from "react";
import Y from "@uiw/react-codemirror";
import { Decoration as A, EditorView as K } from "@codemirror/view";
import { StateEffect as U, StateField as Q } from "@codemirror/state";
import { javascript as Z } from "@codemirror/lang-javascript";
import { tags as m } from "@lezer/highlight";
import { createTheme as ee } from "@uiw/codemirror-themes";
import { useInView as te } from "react-hook-inview";
import { evaluate as G } from "@strudel.cycles/eval";
import { Tone as M } from "@strudel.cycles/tone";
import { TimeSpan as oe, State as re } from "@strudel.cycles/core";
import { webaudioOutputTrigger as ne } from "@strudel.cycles/webaudio";
import { WebMidi as N, enableWebMidi as se } from "@strudel.cycles/midi";
const ae = ee({
import n, { useCallback as N, useRef as x, useEffect as R, useState as w, useMemo as I, useLayoutEffect as K } from "react";
import Q from "@uiw/react-codemirror";
import { Decoration as E, EditorView as O } from "@codemirror/view";
import { StateEffect as j, StateField as U } from "@codemirror/state";
import { javascript as W } from "@codemirror/lang-javascript";
import { tags as r } from "@lezer/highlight";
import { createTheme as X } from "@uiw/codemirror-themes";
import { useInView as Y } from "react-hook-inview";
import { webaudioOutput as Z, getAudioContext as ee } from "@strudel.cycles/webaudio";
import { repl as te } from "@strudel.cycles/core";
import { transpiler as re } from "@strudel.cycles/transpiler";
const oe = X({
theme: "dark",
settings: {
background: "#222",
@ -19,450 +17,294 @@ const ae = ee({
caret: "#ffcc00",
selection: "rgba(128, 203, 196, 0.5)",
selectionMatch: "#036dd626",
lineHighlight: "#8a91991a",
lineHighlight: "#00000050",
gutterBackground: "transparent",
gutterForeground: "#676e95"
gutterForeground: "#8a919966"
},
styles: [
{ tag: m.keyword, color: "#c792ea" },
{ tag: m.operator, color: "#89ddff" },
{ tag: m.special(m.variableName), color: "#eeffff" },
{ tag: m.typeName, color: "#f07178" },
{ tag: m.atom, color: "#f78c6c" },
{ tag: m.number, color: "#ff5370" },
{ tag: m.definition(m.variableName), color: "#82aaff" },
{ tag: m.string, color: "#c3e88d" },
{ tag: m.special(m.string), color: "#f07178" },
{ tag: m.comment, color: "#7d8799" },
{ tag: m.variableName, color: "#f07178" },
{ tag: m.tagName, color: "#ff5370" },
{ tag: m.bracket, color: "#a2a1a4" },
{ tag: m.meta, color: "#ffcb6b" },
{ tag: m.attributeName, color: "#c792ea" },
{ tag: m.propertyName, color: "#c792ea" },
{ tag: m.className, color: "#decb6b" },
{ tag: m.invalid, color: "#ffffff" }
{ tag: r.keyword, color: "#c792ea" },
{ tag: r.operator, color: "#89ddff" },
{ tag: r.special(r.variableName), color: "#eeffff" },
{ tag: r.typeName, color: "#c3e88d" },
{ tag: r.atom, color: "#f78c6c" },
{ tag: r.number, color: "#c3e88d" },
{ tag: r.definition(r.variableName), color: "#82aaff" },
{ tag: r.string, color: "#c3e88d" },
{ tag: r.special(r.string), color: "#c3e88d" },
{ tag: r.comment, color: "#7d8799" },
{ tag: r.variableName, color: "#c792ea" },
{ tag: r.tagName, color: "#c3e88d" },
{ tag: r.bracket, color: "#525154" },
{ tag: r.meta, color: "#ffcb6b" },
{ tag: r.attributeName, color: "#c792ea" },
{ tag: r.propertyName, color: "#c792ea" },
{ tag: r.className, color: "#decb6b" },
{ tag: r.invalid, color: "#ffffff" }
]
});
const z = U.define(), ce = Q.define({
const T = j.define(), ne = U.define({
create() {
return A.none;
return E.none;
},
update(e, o) {
update(e, t) {
try {
for (let r of o.effects)
if (r.is(z))
if (r.value) {
const n = A.mark({ attributes: { style: "background-color: #FFCA2880" } });
e = A.set([n.range(0, o.newDoc.length)]);
for (let o of t.effects)
if (o.is(T))
if (o.value) {
const a = E.mark({ attributes: { style: "background-color: #FFCA2880" } });
e = E.set([a.range(0, t.newDoc.length)]);
} else
e = A.set([]);
e = E.set([]);
return e;
} catch (r) {
return console.warn("flash error", r), e;
} catch (o) {
return console.warn("flash error", o), e;
}
},
provide: (e) => K.decorations.from(e)
}), ie = (e) => {
e.dispatch({ effects: z.of(!0) }), setTimeout(() => {
e.dispatch({ effects: z.of(!1) });
provide: (e) => O.decorations.from(e)
}), ae = (e) => {
e.dispatch({ effects: T.of(!0) }), setTimeout(() => {
e.dispatch({ effects: T.of(!1) });
}, 200);
}, O = U.define(), le = Q.define({
}, A = j.define(), se = U.define({
create() {
return A.none;
return E.none;
},
update(e, o) {
update(e, t) {
try {
for (let r of o.effects)
if (r.is(O)) {
const n = r.value.map(
(s) => (s.context.locations || []).map(({ start: i, end: a }) => {
const t = s.context.color || "#FFCA28";
let l = o.newDoc.line(i.line).from + i.column, c = o.newDoc.line(a.line).from + a.column;
const f = o.newDoc.length;
return l > f || c > f ? void 0 : A.mark({ attributes: { style: `outline: 1.5px solid ${t};` } }).range(l, c);
for (let o of t.effects)
if (o.is(A)) {
const a = o.value.map(
(s) => (s.context.locations || []).map(({ start: m, end: l }) => {
const d = s.context.color || "#FFCA28";
let c = t.newDoc.line(m.line).from + m.column, i = t.newDoc.line(l.line).from + l.column;
const g = t.newDoc.length;
return c > g || i > g ? void 0 : E.mark({ attributes: { style: `outline: 1.5px solid ${d};` } }).range(c, i);
})
).flat().filter(Boolean) || [];
e = A.set(n, !0);
e = E.set(a, !0);
}
return e;
} catch {
return A.set([]);
return E.set([]);
}
},
provide: (e) => K.decorations.from(e)
}), ue = [Z(), ae, le, ce];
function de({ value: e, onChange: o, onViewChanged: r, onSelectionChange: n, options: s, editorDidMount: i }) {
const a = T(
(c) => {
o?.(c);
provide: (e) => O.decorations.from(e)
}), ce = [W(), oe, se, ne];
function ie({ value: e, onChange: t, onViewChanged: o, onSelectionChange: a, options: s, editorDidMount: m }) {
const l = N(
(i) => {
t?.(i);
},
[t]
), d = N(
(i) => {
o?.(i);
},
[o]
), t = T(
(c) => {
r?.(c);
), c = N(
(i) => {
i.selectionSet && a && a?.(i.state.selection);
},
[r]
), l = T(
(c) => {
c.selectionSet && n && n?.(c.state.selection);
},
[n]
[a]
);
return /* @__PURE__ */ y.createElement(y.Fragment, null, /* @__PURE__ */ y.createElement(Y, {
return /* @__PURE__ */ n.createElement(n.Fragment, null, /* @__PURE__ */ n.createElement(Q, {
value: e,
onChange: a,
onCreateEditor: t,
onUpdate: l,
extensions: ue
onChange: l,
onCreateEditor: d,
onUpdate: c,
extensions: ce
}));
}
function fe(e) {
const { onEvent: o, onQuery: r, onSchedule: n, ready: s = !0, onDraw: i } = e, [a, t] = w(!1), l = 1, c = () => Math.floor(M.getTransport().seconds / l), f = (g = c()) => {
const C = new oe(g, g + 1), v = r?.(new re(C)) || [];
n?.(v, g);
const h = C.begin.valueOf();
M.getTransport().cancel(h);
const D = (g + 1) * l - 0.5, _ = Math.max(M.getTransport().seconds, D) + 0.1;
M.getTransport().schedule(() => {
f(g + 1);
}, _), v?.filter((E) => E.part.begin.equals(E.whole?.begin)).forEach((E) => {
M.getTransport().schedule((L) => {
o(L, E, M.getContext().currentTime), M.Draw.schedule(() => {
i?.(L, E);
}, L);
}, E.part.begin.valueOf());
});
};
q(() => {
s && f();
}, [o, n, r, i, s]);
const p = async () => {
t(!0), await M.start(), M.getTransport().start("+0.1");
}, d = () => {
M.getTransport().pause(), t(!1);
};
return {
start: p,
stop: d,
onEvent: o,
started: a,
setStarted: t,
toggle: () => a ? d() : p(),
query: f,
activeCycle: c
};
}
function ge(e) {
return q(() => (window.addEventListener("message", e), () => window.removeEventListener("message", e)), [e]), T((o) => window.postMessage(o, "*"), []);
}
let he = () => Math.floor((1 + Math.random()) * 65536).toString(16).substring(1);
const me = (e) => encodeURIComponent(btoa(e));
function pe({ tune: e, autolink: o = !0, onEvent: r, onDraw: n }) {
const s = R(() => he(), []), [i, a] = w(e), [t, l] = w(), [c, f] = w(""), [p, d] = w(), [k, g] = w(!1), [C, v] = w(""), [h, D] = w(), _ = R(() => i !== t || p, [i, t, p]), E = T((b) => f((u) => u + `${u ? `
` : ""}${b}`), []), L = R(() => {
if (t && !t.includes("strudel disable-highlighting"))
return (b, u) => n?.(b, u, t);
}, [t, n]), H = R(() => t && t.includes("strudel hide-header"), [t]), x = R(() => t && t.includes("strudel hide-console"), [t]), P = fe({
onDraw: L,
onEvent: T(
(b, u, X) => {
try {
r?.(u), u.context.logs?.length && u.context.logs.forEach(E);
const { onTrigger: S = ne } = u.context;
S(b, u, X, 1);
} catch (S) {
console.warn(S), S.message = "unplayable event: " + S?.message, E(S.message);
}
},
[r, E]
),
onQuery: T(
(b) => {
try {
return h?.query(b) || [];
} catch (u) {
return console.warn(u), u.message = "query error: " + u.message, d(u), [];
}
},
[h]
),
onSchedule: T((b, u) => J(b), []),
ready: !!h && !!t
}), B = ge(({ data: { from: b, type: u } }) => {
u === "start" && b !== s && (P.setStarted(!1), l(void 0));
}), V = T(
async (b = i) => {
if (t && !_) {
d(void 0), P.start();
return;
}
try {
g(!0);
const u = await G(b);
P.start(), B({ type: "start", from: s }), D(() => u.pattern), o && (window.location.hash = "#" + encodeURIComponent(btoa(i))), v(me(i)), d(void 0), l(b), g(!1);
} catch (u) {
u.message = "evaluation error: " + u.message, console.warn(u), d(u);
}
},
[t, _, i, P, o, s, B]
), J = (b, u) => {
b.length;
};
return {
hideHeader: H,
hideConsole: x,
pending: k,
code: i,
setCode: a,
pattern: h,
error: p,
cycle: P,
setPattern: D,
dirty: _,
log: c,
togglePlay: () => {
P.started ? P.stop() : V();
},
setActiveCode: l,
activateCode: V,
activeCode: t,
pushLog: E,
hash: C
};
}
function $(...e) {
function B(...e) {
return e.filter(Boolean).join(" ");
}
function ye({ view: e, pattern: o, active: r, getTime: n }) {
const s = I([]), i = I();
q(() => {
function le({ view: e, pattern: t, active: o, getTime: a }) {
const s = x([]), m = x();
R(() => {
if (e)
if (o && r) {
let t = function() {
if (t && o) {
let d = function() {
try {
const l = n(), f = [Math.max(i.current || l, l - 1 / 10), l + 1 / 60];
i.current = l + 1 / 60, s.current = s.current.filter((d) => d.whole.end > l);
const p = o.queryArc(...f).filter((d) => d.hasOnset());
s.current = s.current.concat(p), e.dispatch({ effects: O.of(s.current) });
const c = a(), g = [Math.max(m.current || c, c - 1 / 10, 0), c + 1 / 60];
m.current = g[1], s.current = s.current.filter((p) => p.whole.end > c);
const v = t.queryArc(...g).filter((p) => p.hasOnset());
s.current = s.current.concat(v), e.dispatch({ effects: A.of(s.current) });
} catch {
e.dispatch({ effects: O.of([]) });
e.dispatch({ effects: A.of([]) });
}
a = requestAnimationFrame(t);
}, a = requestAnimationFrame(t);
l = requestAnimationFrame(d);
}, l = requestAnimationFrame(d);
return () => {
cancelAnimationFrame(a);
cancelAnimationFrame(l);
};
} else
s.current = [], e.dispatch({ effects: O.of([]) });
}, [o, r, e]);
s.current = [], e.dispatch({ effects: A.of([]) });
}, [t, o, e]);
}
const be = "_container_3i85k_1", we = "_header_3i85k_5", ve = "_buttons_3i85k_9", Ee = "_button_3i85k_9", ke = "_buttonDisabled_3i85k_17", Ce = "_error_3i85k_21", Me = "_body_3i85k_25", F = {
container: be,
header: we,
buttons: ve,
button: Ee,
buttonDisabled: ke,
error: Ce,
body: Me
const de = "_container_3i85k_1", ue = "_header_3i85k_5", fe = "_buttons_3i85k_9", me = "_button_3i85k_9", ge = "_buttonDisabled_3i85k_17", pe = "_error_3i85k_21", he = "_body_3i85k_25", b = {
container: de,
header: ue,
buttons: fe,
button: me,
buttonDisabled: ge,
error: pe,
body: he
};
function W({ type: e }) {
return /* @__PURE__ */ y.createElement("svg", {
function q({ type: e }) {
return /* @__PURE__ */ n.createElement("svg", {
xmlns: "http://www.w3.org/2000/svg",
className: "sc-h-5 sc-w-5",
viewBox: "0 0 20 20",
fill: "currentColor"
}, {
refresh: /* @__PURE__ */ y.createElement("path", {
refresh: /* @__PURE__ */ n.createElement("path", {
fillRule: "evenodd",
d: "M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z",
clipRule: "evenodd"
}),
play: /* @__PURE__ */ y.createElement("path", {
play: /* @__PURE__ */ n.createElement("path", {
fillRule: "evenodd",
d: "M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z",
clipRule: "evenodd"
}),
pause: /* @__PURE__ */ y.createElement("path", {
pause: /* @__PURE__ */ n.createElement("path", {
fillRule: "evenodd",
d: "M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z",
clipRule: "evenodd"
})
}[e]);
}
function Be({ tune: e, hideOutsideView: o = !1, init: r, onEvent: n, enableKeyboard: s }) {
const { code: i, setCode: a, pattern: t, activeCode: l, activateCode: c, evaluateOnly: f, error: p, cycle: d, dirty: k, togglePlay: g, stop: C } = pe({
tune: e,
autolink: !1,
onEvent: n
});
q(() => {
r && f();
}, [e, r]);
const [v, h] = w(), [D, _] = te({
threshold: 0.01
}), E = I(), L = R(() => ((_ || !o) && (E.current = !0), _ || E.current), [_, o]);
return ye({
view: v,
pattern: t,
active: d.started && !l?.includes("strudel disable-highlighting"),
getTime: () => M.getTransport().seconds
}), j(() => {
if (s) {
const H = async (x) => {
(x.ctrlKey || x.altKey) && (x.code === "Enter" ? (x.preventDefault(), ie(v), await c()) : x.code === "Period" && (d.stop(), x.preventDefault()));
};
return window.addEventListener("keydown", H, !0), () => window.removeEventListener("keydown", H, !0);
function ve({
defaultOutput: e,
interval: t,
getTime: o,
evalOnMount: a = !1,
initialCode: s = "",
autolink: m = !1,
beforeEval: l,
afterEval: d,
onEvalError: c,
onToggle: i
}) {
const [g, v] = w(), [p, D] = w(), [h, k] = w(s), [y, P] = w(h), [z, _] = w(), [C, H] = w(!1), F = h !== y, { scheduler: u, evaluate: L, start: $, stop: G, pause: J } = I(
() => te({
interval: t,
defaultOutput: e,
onSchedulerError: v,
onEvalError: (f) => {
D(f), c?.(f);
},
getTime: o,
transpiler: re,
beforeEval: ({ code: f }) => {
k(f), l?.();
},
afterEval: ({ pattern: f, code: S }) => {
P(S), _(f), D(), v(), m && (window.location.hash = "#" + encodeURIComponent(btoa(S))), d?.();
},
onToggle: (f) => {
H(f), i?.(f);
}
}),
[e, t, o]
), M = N(async (f = !0) => L(h, f), [L, h]), V = x();
return R(() => {
!V.current && a && h && (V.current = !0, M());
}, [M, a, h]), R(() => () => {
u.stop();
}, [u]), {
code: h,
setCode: k,
error: g || p,
schedulerError: g,
scheduler: u,
evalError: p,
evaluate: L,
activateCode: M,
activeCode: y,
isDirty: F,
pattern: z,
started: C,
start: $,
stop: G,
pause: J,
togglePlay: async () => {
C ? u.pause() : await M();
}
}, [s, t, i, c, d, v]), /* @__PURE__ */ y.createElement("div", {
className: F.container,
ref: D
}, /* @__PURE__ */ y.createElement("div", {
className: F.header
}, /* @__PURE__ */ y.createElement("div", {
className: F.buttons
}, /* @__PURE__ */ y.createElement("button", {
className: $(F.button, d.started ? "sc-animate-pulse" : ""),
onClick: () => g()
}, /* @__PURE__ */ y.createElement(W, {
type: d.started ? "pause" : "play"
})), /* @__PURE__ */ y.createElement("button", {
className: $(k ? F.button : F.buttonDisabled),
onClick: () => c()
}, /* @__PURE__ */ y.createElement(W, {
};
}
const be = () => ee().currentTime;
function Pe({ tune: e, hideOutsideView: t = !1, init: o, enableKeyboard: a }) {
const {
code: s,
setCode: m,
evaluate: l,
activateCode: d,
error: c,
isDirty: i,
activeCode: g,
pattern: v,
started: p,
scheduler: D,
togglePlay: h,
stop: k
} = ve({
initialCode: e,
defaultOutput: Z,
getTime: be
}), [y, P] = w(), [z, _] = Y({
threshold: 0.01
}), C = x(), H = I(() => ((_ || !t) && (C.current = !0), _ || C.current), [_, t]);
return le({
view: y,
pattern: v,
active: p && !g?.includes("strudel disable-highlighting"),
getTime: () => D.getPhase()
}), K(() => {
if (a) {
const F = async (u) => {
(u.ctrlKey || u.altKey) && (u.code === "Enter" ? (u.preventDefault(), ae(y), await d()) : u.code === "Period" && (k(), u.preventDefault()));
};
return window.addEventListener("keydown", F, !0), () => window.removeEventListener("keydown", F, !0);
}
}, [a, v, s, l, k, y]), /* @__PURE__ */ n.createElement("div", {
className: b.container,
ref: z
}, /* @__PURE__ */ n.createElement("div", {
className: b.header
}, /* @__PURE__ */ n.createElement("div", {
className: b.buttons
}, /* @__PURE__ */ n.createElement("button", {
className: B(b.button, p ? "sc-animate-pulse" : ""),
onClick: () => h()
}, /* @__PURE__ */ n.createElement(q, {
type: p ? "pause" : "play"
})), /* @__PURE__ */ n.createElement("button", {
className: B(i ? b.button : b.buttonDisabled),
onClick: () => d()
}, /* @__PURE__ */ n.createElement(q, {
type: "refresh"
}))), p && /* @__PURE__ */ y.createElement("div", {
className: F.error
}, p.message)), /* @__PURE__ */ y.createElement("div", {
className: F.body
}, L && /* @__PURE__ */ y.createElement(de, {
value: i,
onChange: a,
onViewChanged: h
}))), c && /* @__PURE__ */ n.createElement("div", {
className: b.error
}, c.message)), /* @__PURE__ */ n.createElement("div", {
className: b.body
}, H && /* @__PURE__ */ n.createElement(ie, {
value: s,
onChange: m,
onViewChanged: P
})));
}
function Te(e, o, r = 0.05, n = 0.1, s = 0.1) {
let i = 0, a = 0, t = 10 ** 4, l = 0.01;
const c = (h) => r = h(r);
s = s || n / 2;
const f = () => {
const h = e(), D = h + n + s;
for (a === 0 && (a = h + l); a < D; )
a = Math.round(a * t) / t, a >= h && o(a, r, i), a < h && console.log("TOO LATE", a), a += r, i++;
};
let p;
const d = () => {
f(), p = setInterval(f, n * 1e3);
}, k = () => clearInterval(p);
return { setDuration: c, start: d, stop: () => {
i = 0, a = 0, k();
}, pause: () => k(), duration: r, getPhase: () => a };
}
class _e {
worker;
pattern;
started = !1;
cps = 1;
getTime;
phase = 0;
constructor({ interval: o, onTrigger: r, onError: n, getTime: s, latency: i = 0.1 }) {
this.getTime = s;
const a = (t) => Math.round(t * 1e3) / 1e3;
this.clock = Te(
s,
(t, l, c) => {
c === 0 && (this.origin = t);
const f = a(t - this.origin);
this.phase = f - i;
const p = a(f + l), d = s();
try {
this.pattern.queryArc(f, p).forEach((g) => {
if (g.part.begin.equals(g.whole.begin)) {
const C = g.whole.begin + this.origin - d + i, v = g.duration * 1;
r?.(g, C, v);
}
});
} catch (k) {
console.warn("scheduler error", k), n?.(k);
}
},
o
);
}
getPhase() {
return this.phase;
}
start() {
if (!this.pattern)
throw new Error("Scheduler: no pattern set! call .setPattern first.");
this.clock.start(), this.started = !0;
}
pause() {
this.clock.stop(), delete this.origin, this.started = !1;
}
stop() {
delete this.origin, this.clock.stop(), this.started = !1;
}
setPattern(o) {
this.pattern = o;
}
setCps(o = 1) {
this.cps = o;
}
log(o, r, n) {
const s = n.filter((i) => i.hasOnset());
console.log(`${o.toFixed(4)} - ${r.toFixed(4)} ${Array(s.length).fill("I").join("")}`);
}
}
function Ve({ defaultOutput: e, interval: o, getTime: r, code: n, evalOnMount: s = !1 }) {
const [i, a] = w(), [t, l] = w(), [c, f] = w(n), [p, d] = w(), k = n !== c, g = R(
() => new _e({ interval: o, onTrigger: e, onError: a, getTime: r }),
[e, o]
), C = T(async () => {
if (!n) {
console.log("no code..");
return;
}
try {
const { pattern: h } = await G(n);
f(n), g?.setPattern(h), d(h), l();
} catch (h) {
l(h), console.warn("eval error", h);
}
}, [n, g]), v = I();
return q(() => {
!v.current && s && (v.current = !0, C());
}, [C, s]), { schedulerError: i, scheduler: g, evalError: t, evaluate: C, activeCode: c, isDirty: k, pattern: p };
}
const $e = (e) => j(() => (window.addEventListener("keydown", e, !0), () => window.removeEventListener("keydown", e, !0)), [e]);
function We(e) {
const { ready: o, connected: r, disconnected: n } = e, [s, i] = w(!0), [a, t] = w(N?.outputs || []);
return q(() => {
se().then(() => {
N.addListener("connected", (c) => {
t([...N.outputs]), r?.(N, c);
}), N.addListener("disconnected", (c) => {
t([...N.outputs]), n?.(N, c);
}), o?.(N), i(!1);
}).catch((c) => {
if (c) {
console.error(c), console.warn("Web Midi could not be enabled..");
return;
}
});
}, [o, r, n, a]), { loading: s, outputs: a, outputByName: (c) => N.getOutputByName(c) };
function ze(e) {
return R(() => (window.addEventListener("message", e), () => window.removeEventListener("message", e)), [e]), N((t) => window.postMessage(t, "*"), []);
}
const He = (e) => K(() => (window.addEventListener("keydown", e, !0), () => window.removeEventListener("keydown", e, !0)), [e]);
export {
de as CodeMirror,
Be as MiniRepl,
$ as cx,
ie as flash,
fe as useCycle,
ye as useHighlighting,
$e as useKeydown,
ge as usePostMessage,
pe as useRepl,
Ve as useStrudel,
We as useWebMidi
ie as CodeMirror,
Pe as MiniRepl,
B as cx,
ae as flash,
le as useHighlighting,
He as useKeydown,
ze as usePostMessage,
ve as useStrudel
};

View File

@ -1 +1 @@
.cm-editor{background-color:transparent!important;height:100%;z-index:11;font-size:16px}.cm-theme-light{width:100%}.cm-line>*{background:#00000095}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.sc-h-5{height:1.25rem}.sc-w-5{width:1.25rem}@keyframes sc-pulse{50%{opacity:.5}}.sc-animate-pulse{animation:sc-pulse 2s cubic-bezier(.4,0,.6,1) infinite}._container_3i85k_1{overflow:hidden;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(34 34 34 / var(--tw-bg-opacity))}._header_3i85k_5{display:flex;justify-content:space-between;border-top-width:1px;--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity))}._buttons_3i85k_9{display:flex}._button_3i85k_9{display:flex;width:4rem;cursor:pointer;align-items:center;justify-content:center;border-right-width:1px;--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity));padding:.25rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}._button_3i85k_9:hover{--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity))}._buttonDisabled_3i85k_17{display:flex;width:4rem;cursor:pointer;cursor:not-allowed;align-items:center;justify-content:center;--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity));padding:.25rem;--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}._error_3i85k_21{padding:.25rem;text-align:right;font-size:.875rem;line-height:1.25rem;--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity))}._body_3i85k_25{position:relative;overflow:auto}
.cm-editor{background-color:transparent!important;height:100%;z-index:11;font-size:18px}.cm-theme-light{width:100%}.cm-line>*{background:#00000095}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.sc-h-5{height:1.25rem}.sc-w-5{width:1.25rem}@keyframes sc-pulse{50%{opacity:.5}}.sc-animate-pulse{animation:sc-pulse 2s cubic-bezier(.4,0,.6,1) infinite}._container_3i85k_1{overflow:hidden;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(34 34 34 / var(--tw-bg-opacity))}._header_3i85k_5{display:flex;justify-content:space-between;border-top-width:1px;--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity))}._buttons_3i85k_9{display:flex}._button_3i85k_9{display:flex;width:4rem;cursor:pointer;align-items:center;justify-content:center;border-right-width:1px;--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity));padding:.25rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}._button_3i85k_9:hover{--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity))}._buttonDisabled_3i85k_17{display:flex;width:4rem;cursor:pointer;cursor:not-allowed;align-items:center;justify-content:center;--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity));padding:.25rem;--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}._error_3i85k_21{padding:.25rem;text-align:right;font-size:.875rem;line-height:1.25rem;--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity))}._body_3i85k_25{position:relative;overflow:auto}

View File

@ -82,7 +82,7 @@ function App() {
view,
pattern,
active: !activeCode?.includes('strudel disable-highlighting'),
getTime: () => scheduler.phase,
getTime: () => scheduler.getPhase(),
});
const error = evalError || schedulerError;

View File

@ -17,6 +17,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"watch": "vite build --watch",
"preview": "vite preview"
},
"repository": {
@ -39,7 +40,8 @@
"dependencies": {
"@codemirror/lang-javascript": "^6.1.1",
"@strudel.cycles/core": "^0.3.2",
"@strudel.cycles/eval": "^0.3.2",
"@strudel.cycles/transpiler": "^0.3.2",
"@strudel.cycles/webaudio": "^0.3.3",
"@strudel.cycles/tone": "^0.3.3",
"@uiw/codemirror-themes": "^4.12.4",
"@uiw/react-codemirror": "^4.12.4",

View File

@ -6,7 +6,7 @@ import { controls, evalScope } from '@strudel.cycles/core';
evalScope(
controls,
import('@strudel.cycles/core'),
import('@strudel.cycles/tone'),
// import('@strudel.cycles/tone'),
import('@strudel.cycles/tonal'),
import('@strudel.cycles/mini'),
import('@strudel.cycles/midi'),

View File

@ -1,6 +1,5 @@
import React, { useState, useMemo, useRef, useEffect, useLayoutEffect } from 'react';
import { useInView } from 'react-hook-inview';
import useRepl from '../hooks/useRepl.mjs';
import cx from '../cx';
import useHighlighting from '../hooks/useHighlighting.mjs';
import CodeMirror6, { flash } from './CodeMirror6';
@ -8,18 +7,33 @@ import 'tailwindcss/tailwind.css';
import './style.css';
import styles from './MiniRepl.module.css';
import { Icon } from './Icon';
import { Tone } from '@strudel.cycles/tone';
import { getAudioContext, webaudioOutput } from '@strudel.cycles/webaudio';
import useStrudel from '../hooks/useStrudel.mjs';
export function MiniRepl({ tune, hideOutsideView = false, init, onEvent, enableKeyboard }) {
const { code, setCode, pattern, activeCode, activateCode, evaluateOnly, error, cycle, dirty, togglePlay, stop } =
useRepl({
tune,
autolink: false,
onEvent,
});
useEffect(() => {
init && evaluateOnly();
}, [tune, init]);
const getTime = () => getAudioContext().currentTime;
export function MiniRepl({ tune, hideOutsideView = false, init, enableKeyboard }) {
const {
code,
setCode,
evaluate,
activateCode,
error,
isDirty,
activeCode,
pattern,
started,
scheduler,
togglePlay,
stop,
} = useStrudel({
initialCode: tune,
defaultOutput: webaudioOutput,
getTime,
});
/* useEffect(() => {
init && activateCode();
}, [init, activateCode]); */
const [view, setView] = useState();
const [ref, isVisible] = useInView({
threshold: 0.01,
@ -34,8 +48,8 @@ export function MiniRepl({ tune, hideOutsideView = false, init, onEvent, enableK
useHighlighting({
view,
pattern,
active: cycle.started && !activeCode?.includes('strudel disable-highlighting'),
getTime: () => Tone.getTransport().seconds,
active: started && !activeCode?.includes('strudel disable-highlighting'),
getTime: () => scheduler.getPhase(),
});
// set active pattern on ctrl+enter
@ -48,7 +62,7 @@ export function MiniRepl({ tune, hideOutsideView = false, init, onEvent, enableK
flash(view);
await activateCode();
} else if (e.code === 'Period') {
cycle.stop();
stop();
e.preventDefault();
}
}
@ -56,16 +70,16 @@ export function MiniRepl({ tune, hideOutsideView = false, init, onEvent, enableK
window.addEventListener('keydown', handleKeyPress, true);
return () => window.removeEventListener('keydown', handleKeyPress, true);
}
}, [enableKeyboard, pattern, code, activateCode, cycle, view]);
}, [enableKeyboard, pattern, code, evaluate, stop, view]);
return (
<div className={styles.container} ref={ref}>
<div className={styles.header}>
<div className={styles.buttons}>
<button className={cx(styles.button, cycle.started ? 'sc-animate-pulse' : '')} onClick={() => togglePlay()}>
<Icon type={cycle.started ? 'pause' : 'play'} />
<button className={cx(styles.button, started ? 'sc-animate-pulse' : '')} onClick={() => togglePlay()}>
<Icon type={started ? 'pause' : 'play'} />
</button>
<button className={cx(dirty ? styles.button : styles.buttonDisabled)} onClick={() => activateCode()}>
<button className={cx(isDirty ? styles.button : styles.buttonDisabled)} onClick={() => activateCode()}>
<Icon type="refresh" />
</button>
</div>

View File

@ -2,7 +2,7 @@
background-color: transparent !important;
height: 100%;
z-index: 11;
font-size: 16px;
font-size: 18px;
}
.cm-theme-light {

View File

@ -1,86 +0,0 @@
/*
useCycle.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/repl/src/useCycle.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/>.
*/
import { useEffect, useState } from 'react';
import { Tone } from '@strudel.cycles/tone';
import { State, TimeSpan } from '@strudel.cycles/core';
/* export declare interface UseCycleProps {
onEvent: ToneEventCallback<any>;
onQuery?: (state: State) => Hap[];
onSchedule?: (events: Hap[], cycle: number) => void;
onDraw?: ToneEventCallback<any>;
ready?: boolean; // if false, query will not be called on change props
} */
// function useCycle(props: UseCycleProps) {
function useCycle(props) {
// onX must use useCallback!
const { onEvent, onQuery, onSchedule, ready = true, onDraw } = props;
const [started, setStarted] = useState(false);
const cycleDuration = 1;
const activeCycle = () => Math.floor(Tone.getTransport().seconds / cycleDuration);
// pull events with onQuery + count up to next cycle
const query = (cycle = activeCycle()) => {
const timespan = new TimeSpan(cycle, cycle + 1);
const events = onQuery?.(new State(timespan)) || [];
onSchedule?.(events, cycle);
// cancel events after current query. makes sure no old events are player for rescheduled cycles
// console.log('schedule', cycle);
// query next cycle in the middle of the current
const cancelFrom = timespan.begin.valueOf();
Tone.getTransport().cancel(cancelFrom);
// const queryNextTime = (cycle + 1) * cycleDuration - 0.1;
const queryNextTime = (cycle + 1) * cycleDuration - 0.5;
// if queryNextTime would be before current time, execute directly (+0.1 for safety that it won't miss)
const t = Math.max(Tone.getTransport().seconds, queryNextTime) + 0.1;
Tone.getTransport().schedule(() => {
query(cycle + 1);
}, t);
// schedule events for next cycle
events
?.filter((event) => event.part.begin.equals(event.whole?.begin))
.forEach((event) => {
Tone.getTransport().schedule((time) => {
onEvent(time, event, Tone.getContext().currentTime);
Tone.Draw.schedule(() => {
// do drawing or DOM manipulation here
onDraw?.(time, event);
}, time);
}, event.part.begin.valueOf());
});
};
useEffect(() => {
ready && query();
}, [onEvent, onSchedule, onQuery, onDraw, ready]);
const start = async () => {
setStarted(true);
await Tone.start();
Tone.getTransport().start('+0.1');
};
const stop = () => {
Tone.getTransport().pause();
setStarted(false);
};
const toggle = () => (started ? stop() : start());
return {
start,
stop,
onEvent,
started,
setStarted,
toggle,
query,
activeCycle,
};
}
export default useCycle;

View File

@ -14,15 +14,14 @@ function useHighlighting({ view, pattern, active, getTime }) {
const audioTime = getTime();
// force min framerate of 10 fps => fixes crash on tab refocus, where lastEnd could be far away
// see https://github.com/tidalcycles/strudel/issues/108
const begin = Math.max(lastEnd.current || audioTime, audioTime - 1 / 10);
const begin = Math.max(lastEnd.current || audioTime, audioTime - 1 / 10, 0); // negative time seems buggy
const span = [begin, audioTime + 1 / 60];
lastEnd.current = audioTime + 1 / 60;
lastEnd.current = span[1];
highlights.current = highlights.current.filter((hap) => hap.whole.end > audioTime); // keep only highlights that are still active
const haps = pattern.queryArc(...span).filter((hap) => hap.hasOnset());
highlights.current = highlights.current.concat(haps); // add potential new onsets
view.dispatch({ effects: setHighlights.of(highlights.current) }); // highlight all still active + new active haps
} catch (err) {
// console.log('error in updateHighlights', err);
view.dispatch({ effects: setHighlights.of([]) });
}
frame = requestAnimationFrame(updateHighlights);

View File

@ -1,150 +0,0 @@
/*
useRepl.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/repl/src/useRepl.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/>.
*/
import { useCallback, useState, useMemo } from 'react';
import { evaluate } from '@strudel.cycles/eval';
import useCycle from './useCycle.mjs';
import usePostMessage from './usePostMessage.mjs';
import { webaudioOutputTrigger } from '@strudel.cycles/webaudio';
let s4 = () => {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
};
const generateHash = (code) => encodeURIComponent(btoa(code));
function useRepl({ tune, autolink = true, onEvent, onDraw: onDrawProp }) {
const id = useMemo(() => s4(), []);
const [code, setCode] = useState(tune);
const [activeCode, setActiveCode] = useState();
const [log, setLog] = useState('');
const [error, setError] = useState();
const [pending, setPending] = useState(false);
const [hash, setHash] = useState('');
const [pattern, setPattern] = useState();
const dirty = useMemo(() => code !== activeCode || error, [code, activeCode, error]);
const pushLog = useCallback((message) => setLog((log) => log + `${log ? '\n\n' : ''}${message}`), []);
// below block allows disabling the highlighting by including "strudel disable-highlighting" in the code (as comment)
const onDraw = useMemo(() => {
if (activeCode && !activeCode.includes('strudel disable-highlighting')) {
return (time, event) => onDrawProp?.(time, event, activeCode);
}
}, [activeCode, onDrawProp]);
const hideHeader = useMemo(() => activeCode && activeCode.includes('strudel hide-header'), [activeCode]);
const hideConsole = useMemo(() => activeCode && activeCode.includes('strudel hide-console'), [activeCode]);
// cycle hook to control scheduling
const cycle = useCycle({
onDraw,
onEvent: useCallback(
(time, event, currentTime) => {
try {
onEvent?.(event);
if (event.context.logs?.length) {
event.context.logs.forEach(pushLog);
}
const { onTrigger = webaudioOutputTrigger } = event.context;
onTrigger(time, event, currentTime, 1 /* cps */);
} catch (err) {
console.warn(err);
err.message = 'unplayable event: ' + err?.message;
pushLog(err.message); // not with setError, because then we would have to setError(undefined) on next playable event
}
},
[onEvent, pushLog],
),
onQuery: useCallback(
(state) => {
try {
return pattern?.query(state) || [];
} catch (err) {
console.warn(err);
err.message = 'query error: ' + err.message;
setError(err);
return [];
}
},
[pattern],
),
onSchedule: useCallback((_events, cycle) => logCycle(_events, cycle), []),
ready: !!pattern && !!activeCode,
});
const broadcast = usePostMessage(({ data: { from, type } }) => {
if (type === 'start' && from !== id) {
// console.log('message', from, type);
cycle.setStarted(false);
setActiveCode(undefined);
}
});
const activateCode = useCallback(
async (_code = code) => {
if (activeCode && !dirty) {
setError(undefined);
cycle.start();
return;
}
try {
setPending(true);
const parsed = await evaluate(_code);
cycle.start();
broadcast({ type: 'start', from: id });
setPattern(() => parsed.pattern);
if (autolink) {
window.location.hash = '#' + encodeURIComponent(btoa(code));
}
setHash(generateHash(code));
setError(undefined);
setActiveCode(_code);
setPending(false);
} catch (err) {
err.message = 'evaluation error: ' + err.message;
console.warn(err);
setError(err);
}
},
[activeCode, dirty, code, cycle, autolink, id, broadcast],
);
// logs events of cycle
const logCycle = (_events, cycle) => {
if (_events.length) {
// pushLog(`# cycle ${cycle}\n` + _events.map((e: any) => e.show()).join('\n'));
}
};
const togglePlay = () => {
if (!cycle.started) {
activateCode();
} else {
cycle.stop();
}
};
return {
hideHeader,
hideConsole,
pending,
code,
setCode,
pattern,
error,
cycle,
setPattern,
dirty,
log,
togglePlay,
setActiveCode,
activateCode,
activeCode,
pushLog,
hash,
};
}
export default useRepl;

View File

@ -1,43 +1,105 @@
import { useRef, useCallback, useEffect, useMemo, useState } from 'react';
import { repl } from '@strudel.cycles/core/repl.mjs';
import { repl } from '@strudel.cycles/core';
import { transpiler } from '@strudel.cycles/transpiler';
function useStrudel({ defaultOutput, interval, getTime, code, evalOnMount = false }) {
function useStrudel({
defaultOutput,
interval,
getTime,
evalOnMount = false,
initialCode = '',
autolink = false,
beforeEval,
afterEval,
onEvalError,
onToggle,
}) {
// scheduler
const [schedulerError, setSchedulerError] = useState();
const [evalError, setEvalError] = useState();
const [code, setCode] = useState(initialCode);
const [activeCode, setActiveCode] = useState(code);
const [pattern, setPattern] = useState();
const [started, setStarted] = useState(false);
const isDirty = code !== activeCode;
const { scheduler, evaluate: _evaluate } = useMemo(
// TODO: make sure this hook reruns when scheduler.started changes
const { scheduler, evaluate, start, stop, pause } = useMemo(
() =>
repl({
interval,
defaultOutput,
onSchedulerError: setSchedulerError,
onEvalError: setEvalError,
onEvalError: (err) => {
setEvalError(err);
onEvalError?.(err);
},
getTime,
transpiler,
onEval: ({ pattern: _pattern, code }) => {
beforeEval: ({ code }) => {
setCode(code);
beforeEval?.();
},
afterEval: ({ pattern: _pattern, code }) => {
setActiveCode(code);
setPattern(_pattern);
setEvalError();
setSchedulerError();
if (autolink) {
window.location.hash = '#' + encodeURIComponent(btoa(code));
}
afterEval?.();
},
onToggle: (v) => {
setStarted(v);
onToggle?.(v);
},
onEvalError: setEvalError,
}),
[defaultOutput, interval, getTime],
);
const evaluate = useCallback(() => _evaluate(code), [_evaluate, code]);
const activateCode = useCallback(async (autostart = true) => evaluate(code, autostart), [evaluate, code]);
const inited = useRef();
useEffect(() => {
if (!inited.current && evalOnMount && code) {
inited.current = true;
evaluate();
activateCode();
}
}, [evaluate, evalOnMount, code]);
}, [activateCode, evalOnMount, code]);
return { schedulerError, scheduler, evalError, evaluate, activeCode, isDirty, pattern };
// this will stop the scheduler when hot reloading in development
useEffect(() => {
return () => {
scheduler.stop();
};
}, [scheduler]);
const togglePlay = async () => {
if (started) {
scheduler.pause();
} else {
await activateCode();
}
};
const error = schedulerError || evalError;
return {
code,
setCode,
error,
schedulerError,
scheduler,
evalError,
evaluate,
activateCode,
activeCode,
isDirty,
pattern,
started,
start,
stop,
pause,
togglePlay,
};
}
export default useStrudel;

View File

@ -1,41 +0,0 @@
/*
useWebMidi.js - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/repl/src/useWebMidi.js>
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/>.
*/
import { useEffect, useState } from 'react';
import { enableWebMidi, WebMidi } from '@strudel.cycles/midi'
export function useWebMidi(props) {
const { ready, connected, disconnected } = props;
const [loading, setLoading] = useState(true);
const [outputs, setOutputs] = useState(WebMidi?.outputs || []);
useEffect(() => {
enableWebMidi()
.then(() => {
// Reacting when a new device becomes available
WebMidi.addListener('connected', (e) => {
setOutputs([...WebMidi.outputs]);
connected?.(WebMidi, e);
});
// Reacting when a device becomes unavailable
WebMidi.addListener('disconnected', (e) => {
setOutputs([...WebMidi.outputs]);
disconnected?.(WebMidi, e);
});
ready?.(WebMidi);
setLoading(false);
})
.catch((err) => {
if (err) {
console.error(err);
//throw new Error("Web Midi could not be enabled...");
console.warn('Web Midi could not be enabled..');
return;
}
});
}, [ready, connected, disconnected, outputs]);
const outputByName = (name) => WebMidi.getOutputByName(name);
return { loading, outputs, outputByName };
}

View File

@ -2,11 +2,8 @@
export { default as CodeMirror, flash } from './components/CodeMirror6';
export * from './components/MiniRepl';
export { default as useCycle } from './hooks/useCycle';
export { default as useHighlighting } from './hooks/useHighlighting';
export { default as usePostMessage } from './hooks/usePostMessage';
export { default as useRepl } from './hooks/useRepl';
export { default as useStrudel } from './hooks/useStrudel';
export { default as useKeydown } from './hooks/useKeydown';
export { default as cx } from './cx';
export { useWebMidi } from './hooks/useWebMidi';

View File

@ -8,28 +8,36 @@ export default createTheme({
caret: '#ffcc00',
selection: 'rgba(128, 203, 196, 0.5)',
selectionMatch: '#036dd626',
lineHighlight: '#8a91991a',
// lineHighlight: '#8a91991a', // original
lineHighlight: '#00000050',
gutterBackground: 'transparent',
// gutterForeground: '#8a919966',
gutterForeground: '#676e95',
gutterForeground: '#8a919966',
},
styles: [
{ tag: t.keyword, color: '#c792ea' },
{ tag: t.operator, color: '#89ddff' },
{ tag: t.special(t.variableName), color: '#eeffff' },
{ tag: t.typeName, color: '#f07178' },
// { tag: t.typeName, color: '#f07178' }, // original
{ tag: t.typeName, color: '#c3e88d' },
{ tag: t.atom, color: '#f78c6c' },
{ tag: t.number, color: '#ff5370' },
// { tag: t.number, color: '#ff5370' }, // original
{ tag: t.number, color: '#c3e88d' },
{ tag: t.definition(t.variableName), color: '#82aaff' },
{ tag: t.string, color: '#c3e88d' },
{ tag: t.special(t.string), color: '#f07178' },
// { tag: t.special(t.string), color: '#f07178' }, // original
{ tag: t.special(t.string), color: '#c3e88d' },
{ tag: t.comment, color: '#7d8799' },
{ tag: t.variableName, color: '#f07178' },
{ tag: t.tagName, color: '#ff5370' },
{ tag: t.bracket, color: '#a2a1a4' },
// { tag: t.variableName, color: '#f07178' }, // original
{ tag: t.variableName, color: '#c792ea' },
// { tag: t.tagName, color: '#ff5370' }, // original
{ tag: t.tagName, color: '#c3e88d' },
{ tag: t.bracket, color: '#525154' },
// { tag: t.bracket, color: '#a2a1a4' }, // original
{ tag: t.meta, color: '#ffcb6b' },
{ tag: t.attributeName, color: '#c792ea' },
{ tag: t.propertyName, color: '#c792ea' },
{ tag: t.className, color: '#decb6b' },
{ tag: t.invalid, color: '#ffffff' },
],

View File

@ -24,8 +24,9 @@ export default defineConfig({
// TODO: find out which of below names are obsolete now
'@strudel.cycles/tone',
'@strudel.cycles/eval',
'@strudel.cycles/transpiler',
'acorn',
'@strudel.cycles/core',
'@strudel.cycles/core/util.mjs',
'@strudel.cycles/mini',
'@strudel.cycles/tonal',
'@strudel.cycles/midi',

View File

@ -9,7 +9,7 @@ import { Pattern, isPattern } from '@strudel.cycles/core';
var serialWriter;
var choosing = false;
export async function getWriter(br=38400) {
export async function getWriter(br = 38400) {
if (choosing) {
return;
}
@ -24,11 +24,10 @@ export async function getWriter(br=38400) {
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);
const writer = textEncoder.writable.getWriter();
serialWriter = function (message) {
writer.write(message)
}
}
else {
throw('Webserial is not available in this browser.')
writer.write(message);
};
} else {
throw 'Webserial is not available in this browser.';
}
}
@ -40,7 +39,7 @@ Pattern.prototype.serial = function (...args) {
getWriter(...args);
}
const onTrigger = (time, hap, currentTime) => {
var message = "";
var message = '';
if (typeof hap.value === 'object') {
if ('action' in hap.value) {
message += hap.value['action'] + '(';
@ -51,26 +50,23 @@ Pattern.prototype.serial = function (...args) {
}
if (first) {
first = false;
} else {
message += ',';
}
else {
message +=',';
}
message += `${key}:${val}`
message += `${key}:${val}`;
}
message += ')';
}
else {
} else {
for (const [key, val] of Object.entries(hap.value)) {
message += `${key}:${val};`
message += `${key}:${val};`;
}
}
}
else {
} else {
message = hap.value;
}
const offset = (time - currentTime + latency) * 1000;
window.setTimeout(serialWriter, offset, message);
};
return hap.setContext({ ...hap.context, onTrigger });
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
});
};

View File

@ -1,5 +1 @@
import './pianoroll.mjs';
export * from './tone.mjs';
export * from './draw.mjs';
export * from './ui.mjs';

View File

@ -50,32 +50,29 @@ export const getDefaultSynth = () => {
// with this function, you can play the pattern with any tone synth
Pattern.prototype.tone = function (instrument) {
return this._withHap((hap) => {
const onTrigger = (time, hap) => {
let note;
let velocity = hap.context?.velocity ?? 0.75;
if (instrument instanceof PluckSynth) {
note = getPlayableNoteValue(hap);
instrument.triggerAttack(note, time);
} else if (instrument instanceof NoiseSynth) {
instrument.triggerAttackRelease(hap.duration.valueOf(), time); // noise has no value
} else if (instrument instanceof Sampler) {
note = getPlayableNoteValue(hap);
instrument.triggerAttackRelease(note, hap.duration.valueOf(), time, velocity);
} else if (instrument instanceof Players) {
if (!instrument.has(hap.value)) {
throw new Error(`name "${hap.value}" not defined for players`);
}
const player = instrument.player(hap.value);
// velocity ?
player.start(time);
player.stop(time + hap.duration.valueOf());
} else {
note = getPlayableNoteValue(hap);
instrument.triggerAttackRelease(note, hap.duration.valueOf(), time, velocity);
return this.onTrigger((time, hap) => {
let note;
let velocity = hap.context?.velocity ?? 0.75;
if (instrument instanceof PluckSynth) {
note = getPlayableNoteValue(hap);
instrument.triggerAttack(note, time);
} else if (instrument instanceof NoiseSynth) {
instrument.triggerAttackRelease(hap.duration.valueOf(), time); // noise has no value
} else if (instrument instanceof Sampler) {
note = getPlayableNoteValue(hap);
instrument.triggerAttackRelease(note, hap.duration.valueOf(), time, velocity);
} else if (instrument instanceof Players) {
if (!instrument.has(hap.value)) {
throw new Error(`name "${hap.value}" not defined for players`);
}
};
return hap.setContext({ ...hap.context, instrument, onTrigger });
const player = instrument.player(hap.value);
// velocity ?
player.start(time);
player.stop(time + hap.duration.valueOf());
} else {
note = getPlayableNoteValue(hap);
instrument.triggerAttackRelease(note, hap.duration.valueOf(), time, velocity);
}
});
};

View File

@ -1,13 +1,87 @@
import { logger } from '@strudel.cycles/core';
const bufferCache = {}; // string: Promise<ArrayBuffer>
const loadCache = {}; // string: Promise<ArrayBuffer>
export const getCachedBuffer = (url) => bufferCache[url];
export const loadBuffer = (url, ac) => {
function humanFileSize(bytes, si) {
var thresh = si ? 1000 : 1024;
if (bytes < thresh) return bytes + ' B';
var units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
var u = -1;
do {
bytes /= thresh;
++u;
} while (bytes >= thresh);
return bytes.toFixed(1) + ' ' + units[u];
}
export const getSampleBufferSource = async (s, n, note, speed) => {
let transpose = 0;
let midi = typeof note === 'string' ? toMidi(note) : note || 36;
transpose = midi - 36; // C3 is middle C
const ac = getAudioContext();
// is sample from loaded samples(..)
const samples = getLoadedSamples();
if (!samples) {
throw new Error('no samples loaded');
}
const bank = samples?.[s];
if (!bank) {
throw new Error(
`sample not found: "${s}"`,
// , try one of ${Object.keys(samples)
// .map((s) => `"${s}"`)
// .join(', ')}.
);
}
if (typeof bank !== 'object') {
throw new Error('wrong format for sample bank:', s);
}
let sampleUrl;
if (Array.isArray(bank)) {
sampleUrl = bank[n % bank.length];
} else {
const midiDiff = (noteA) => toMidi(noteA) - midi;
// object format will expect keys as notes
const closest = Object.keys(bank)
.filter((k) => !k.startsWith('_'))
.reduce(
(closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest),
null,
);
transpose = -midiDiff(closest); // semitones to repitch
sampleUrl = bank[closest][n % bank[closest].length];
}
let buffer = await loadBuffer(sampleUrl, ac, s, n);
if (speed < 0) {
// should this be cached?
buffer = reverseBuffer(buffer);
}
const bufferSource = ac.createBufferSource();
bufferSource.buffer = buffer;
const playbackRate = 1.0 * Math.pow(2, transpose / 12);
// bufferSource.playbackRate.value = Math.pow(2, transpose / 12);
bufferSource.playbackRate.value = playbackRate;
return bufferSource;
};
export const loadBuffer = (url, ac, s, n = 0) => {
const label = s ? `sound "${s}:${n}"` : 'sample';
if (!loadCache[url]) {
logger(`[sampler] load ${label}..`, 'load-sample', { url });
const timestamp = Date.now();
loadCache[url] = fetch(url)
.then((res) => res.arrayBuffer())
.then(async (res) => {
const took = Date.now() - timestamp;
const size = humanFileSize(res.byteLength);
// const downSpeed = humanFileSize(res.byteLength / took);
logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url });
const decoded = await ac.decodeAudioData(res);
bufferCache[url] = decoded;
return decoded;
@ -29,66 +103,7 @@ export const getLoadedBuffer = (url) => {
return bufferCache[url];
};
/* export const playBuffer = (buffer, time = ac.currentTime, destination = ac.destination) => {
const src = ac.createBufferSource();
src.buffer = buffer;
src.connect(destination);
src.start(time);
};
export const playSample = async (url) => playBuffer(await loadBuffer(url)); */
// https://estuary.mcmaster.ca/samples/resources.json
// Array<{ "url":string, "bank": string, "n": number}>
// ritchse/tidal-drum-machines/tree/main/machines/AkaiLinn
const githubCache = {};
let sampleCache = { current: undefined };
export const loadGithubSamples = async (path, nameFn) => {
const storageKey = 'loadGithubSamples ' + path;
const stored = localStorage.getItem(storageKey);
if (stored) {
console.log('[sampler]: loaded sample list from localstorage', path);
githubCache[path] = JSON.parse(stored);
}
if (githubCache[path]) {
sampleCache.current = githubCache[path];
return githubCache[path];
}
console.log('[sampler]: fetching sample list from github', path);
try {
const [user, repo, ...folders] = path.split('/');
const baseUrl = `https://api.github.com/repos/${user}/${repo}/contents`;
const banks = await fetch(`${baseUrl}/${folders.join('/')}`).then((res) => res.json());
// fetch each subfolder
githubCache[path] = (
await Promise.all(
banks.map(async ({ name, path }) => ({
name,
content: await fetch(`${baseUrl}/${path}`)
.then((res) => res.json())
.catch((err) => {
console.error('could not load path', err);
}),
})),
)
)
.filter(({ content }) => !!content)
.reduce(
(acc, { name, content }) => ({
...acc,
[nameFn?.(name) || name]: content.map(({ download_url }) => download_url),
}),
{},
);
} catch (err) {
console.error('[sampler]: failed to fetch sample list from github', err);
return;
}
sampleCache.current = githubCache[path];
localStorage.setItem(storageKey, JSON.stringify(sampleCache.current));
console.log('[sampler]: loaded samples:', sampleCache.current);
return githubCache[path];
};
/**
* Loads a collection of samples to use with `s`

View File

@ -6,10 +6,10 @@ This program is free software: you can redistribute it and/or modify it under th
// import { Pattern, getFrequency, patternify2 } from '@strudel.cycles/core';
import * as strudel from '@strudel.cycles/core';
import { fromMidi, isNote, toMidi } from '@strudel.cycles/core';
import { fromMidi, logger, toMidi } from '@strudel.cycles/core';
import './feedbackdelay.mjs';
import './reverb.mjs';
import { loadBuffer, reverseBuffer } from './sampler.mjs';
import { getSampleBufferSource } from './sampler.mjs';
const { Pattern } = strudel;
import './vowel.mjs';
import workletsUrl from './worklets.mjs?url';
@ -98,56 +98,6 @@ const getSoundfontKey = (s) => {
return;
};
const getSampleBufferSource = async (s, n, note, speed) => {
let transpose = 0;
let midi = typeof note === 'string' ? toMidi(note) : note || 36;
transpose = midi - 36; // C3 is middle C
const ac = getAudioContext();
// is sample from loaded samples(..)
const samples = getLoadedSamples();
if (!samples) {
throw new Error('no samples loaded');
}
const bank = samples?.[s];
if (!bank) {
throw new Error(
`sample not found: "${s}", try one of ${Object.keys(samples)
.map((s) => `"${s}"`)
.join(', ')}.`,
);
}
if (typeof bank !== 'object') {
throw new Error('wrong format for sample bank:', s);
}
let sampleUrl;
if (Array.isArray(bank)) {
sampleUrl = bank[n % bank.length];
} else {
const midiDiff = (noteA) => toMidi(noteA) - midi;
// object format will expect keys as notes
const closest = Object.keys(bank)
.filter((k) => !k.startsWith('_'))
.reduce(
(closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest),
null,
);
transpose = -midiDiff(closest); // semitones to repitch
sampleUrl = bank[closest][n % bank[closest].length];
}
let buffer = await loadBuffer(sampleUrl, ac);
if (speed < 0) {
// should this be cached?
buffer = reverseBuffer(buffer);
}
const bufferSource = ac.createBufferSource();
bufferSource.buffer = buffer;
const playbackRate = 1.0 * Math.pow(2, transpose / 12);
// bufferSource.playbackRate.value = Math.pow(2, transpose / 12);
bufferSource.playbackRate.value = playbackRate;
return bufferSource;
};
const splitSN = (s, n) => {
if (!s.includes(':')) {
return [s, n];
@ -176,14 +126,25 @@ function getWorklet(ac, processor, params) {
return node;
}
if (typeof window !== 'undefined') {
try {
loadWorklets();
} catch (err) {
console.warn('could not load AudioWorklet effects coarse, crush and shape', err);
// this function should be called on first user interaction (to avoid console warning)
export function initAudio() {
if (typeof window !== 'undefined') {
try {
getAudioContext().resume();
loadWorklets();
} catch (err) {
console.warn('could not load AudioWorklet effects coarse, crush and shape', err);
}
}
}
export function initAudioOnFirstClick() {
document.addEventListener('click', function listener() {
initAudio();
document.removeEventListener('click', listener);
});
}
function gainNode(value) {
const node = getAudioContext().createGain();
node.gain.value = value;
@ -229,202 +190,194 @@ function effectSend(input, effect, wet) {
// export const webaudioOutput = async (t, hap, ct, cps) => {
export const webaudioOutput = async (hap, deadline, hapDuration) => {
try {
const ac = getAudioContext();
/* if (isNote(hap.value)) {
const ac = getAudioContext();
/* if (isNote(hap.value)) {
// supports primitive hap values that look like notes
hap.value = { note: hap.value };
} */
if (typeof hap.value !== 'object') {
throw new Error(
`hap.value ${hap.value} is not supported by webaudio output. Hint: append .note() or .s() to the end`,
);
}
// calculate correct time (tone.js workaround)
let t = ac.currentTime + deadline;
// destructure value
let {
freq,
s,
bank,
sf,
clip = 0, // if 1, samples will be cut off when the hap ends
n = 0,
note,
gain = 0.8,
cutoff,
resonance = 1,
hcutoff,
hresonance = 1,
bandf,
bandq = 1,
coarse,
crush,
shape,
pan,
speed = 1, // sample playback speed
begin = 0,
end = 1,
vowel,
delay = 0,
delayfeedback = 0.5,
delaytime = 0.25,
unit,
nudge = 0, // TODO: is this in seconds?
cut,
loop,
orbit = 1,
room,
size = 2,
roomsize = size,
} = hap.value;
const { velocity = 1 } = hap.context;
gain *= velocity; // legacy fix for velocity
// the chain will hold all audio nodes that connect to each other
const chain = [];
if (bank && s) {
s = `${bank}_${s}`;
}
if (typeof s === 'string') {
[s, n] = splitSN(s, n);
}
if (typeof note === 'string') {
[note, n] = splitSN(note, n);
}
if (!s || ['sine', 'square', 'triangle', 'sawtooth'].includes(s)) {
// destructure adsr here, because the default should be different for synths and samples
const { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = hap.value;
// with synths, n and note are the same thing
n = note || n || 36;
if (typeof n === 'string') {
n = toMidi(n); // e.g. c3 => 48
}
// get frequency
if (!freq && typeof n === 'number') {
freq = fromMidi(n); // + 48);
}
// make oscillator
const o = getOscillator({ t, s, freq, duration: hapDuration, release });
chain.push(o);
// level down oscillators as they are really loud compared to samples i've tested
chain.push(gainNode(0.3));
// TODO: make adsr work with samples without pops
// envelope
const adsr = getADSR(attack, decay, sustain, release, 1, t, t + hapDuration);
chain.push(adsr);
} else {
// destructure adsr here, because the default should be different for synths and samples
const { attack = 0.001, decay = 0.001, sustain = 1, release = 0.001 } = hap.value;
// load sample
if (speed === 0) {
// no playback
return;
}
if (!s) {
console.warn('no sample specified');
return;
}
const soundfont = getSoundfontKey(s);
let bufferSource;
try {
if (soundfont) {
// is soundfont
bufferSource = await globalThis.getFontBufferSource(soundfont, note || n, ac);
} else {
// is sample from loaded samples(..)
bufferSource = await getSampleBufferSource(s, n, note, speed);
}
} catch (err) {
console.warn(err);
return;
}
// asny stuff above took too long?
if (ac.currentTime > t) {
console.warn('sample still loading:', s, n);
return;
}
if (!bufferSource) {
console.warn('no buffer source');
return;
}
bufferSource.playbackRate.value = Math.abs(speed) * bufferSource.playbackRate.value;
if (unit === 'c') {
// are there other units?
bufferSource.playbackRate.value = bufferSource.playbackRate.value * bufferSource.buffer.duration;
}
let duration = soundfont || clip ? hapDuration : bufferSource.buffer.duration / bufferSource.playbackRate.value;
// "The computation of the offset into the sound is performed using the sound buffer's natural sample rate,
// rather than the current playback rate, so even if the sound is playing at twice its normal speed,
// the midway point through a 10-second audio buffer is still 5."
const offset = begin * duration * bufferSource.playbackRate.value;
duration = (end - begin) * duration;
if (loop) {
bufferSource.loop = true;
bufferSource.loopStart = offset;
bufferSource.loopEnd = offset + duration;
duration = loop * duration;
}
t += nudge;
bufferSource.start(t, offset);
if (cut !== undefined) {
cutGroups[cut]?.stop(t); // fade out?
cutGroups[cut] = bufferSource;
}
chain.push(bufferSource);
bufferSource.stop(t + duration + release);
const adsr = getADSR(attack, decay, sustain, release, 1, t, t + duration);
chain.push(adsr);
}
// gain stage
chain.push(gainNode(gain));
// filters
cutoff !== undefined && chain.push(getFilter('lowpass', cutoff, resonance));
hcutoff !== undefined && chain.push(getFilter('highpass', hcutoff, hresonance));
bandf !== undefined && chain.push(getFilter('bandpass', bandf, bandq));
vowel !== undefined && chain.push(ac.createVowelFilter(vowel));
// effects
coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse }));
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape }));
// panning
if (pan !== undefined) {
const panner = ac.createStereoPanner();
panner.pan.value = 2 * pan - 1;
chain.push(panner);
}
// last gain
const post = gainNode(1);
chain.push(post);
post.connect(getDestination());
// delay
let delaySend;
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
const delyNode = getDelay(orbit, delaytime, delayfeedback, t);
delaySend = effectSend(post, delyNode, delay);
}
// reverb
let reverbSend;
if (room > 0 && roomsize > 0) {
const reverbNode = getReverb(orbit, roomsize);
reverbSend = effectSend(post, reverbNode, room);
}
// connect chain elements together
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
// disconnect all nodes when source node has ended:
chain[0].onended = () => chain.concat([delaySend, reverbSend]).forEach((n) => n?.disconnect());
} catch (e) {
console.warn('.out error:', e);
if (typeof hap.value !== 'object') {
throw new Error(
`hap.value ${hap.value} is not supported by webaudio output. Hint: append .note() or .s() to the end`,
);
}
// calculate correct time (tone.js workaround)
let t = ac.currentTime + deadline;
// destructure value
let {
freq,
s,
bank,
sf,
clip = 0, // if 1, samples will be cut off when the hap ends
n = 0,
note,
gain = 0.8,
cutoff,
resonance = 1,
hcutoff,
hresonance = 1,
bandf,
bandq = 1,
coarse,
crush,
shape,
pan,
speed = 1, // sample playback speed
begin = 0,
end = 1,
vowel,
delay = 0,
delayfeedback = 0.5,
delaytime = 0.25,
unit,
nudge = 0, // TODO: is this in seconds?
cut,
loop,
orbit = 1,
room,
size = 2,
roomsize = size,
} = hap.value;
const { velocity = 1 } = hap.context;
gain *= velocity; // legacy fix for velocity
// the chain will hold all audio nodes that connect to each other
const chain = [];
if (bank && s) {
s = `${bank}_${s}`;
}
if (typeof s === 'string') {
[s, n] = splitSN(s, n);
}
if (typeof note === 'string') {
[note, n] = splitSN(note, n);
}
if (!s || ['sine', 'square', 'triangle', 'sawtooth'].includes(s)) {
// destructure adsr here, because the default should be different for synths and samples
const { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = hap.value;
// with synths, n and note are the same thing
n = note || n || 36;
if (typeof n === 'string') {
n = toMidi(n); // e.g. c3 => 48
}
// get frequency
if (!freq && typeof n === 'number') {
freq = fromMidi(n); // + 48);
}
// make oscillator
const o = getOscillator({ t, s, freq, duration: hapDuration, release });
chain.push(o);
// level down oscillators as they are really loud compared to samples i've tested
chain.push(gainNode(0.3));
// TODO: make adsr work with samples without pops
// envelope
const adsr = getADSR(attack, decay, sustain, release, 1, t, t + hapDuration);
chain.push(adsr);
} else {
// destructure adsr here, because the default should be different for synths and samples
const { attack = 0.001, decay = 0.001, sustain = 1, release = 0.001 } = hap.value;
// load sample
if (speed === 0) {
// no playback
return;
}
if (!s) {
console.warn('no sample specified');
return;
}
const soundfont = getSoundfontKey(s);
let bufferSource;
if (soundfont) {
// is soundfont
bufferSource = await globalThis.getFontBufferSource(soundfont, note || n, ac);
} else {
// is sample from loaded samples(..)
bufferSource = await getSampleBufferSource(s, n, note, speed);
}
// asny stuff above took too long?
if (ac.currentTime > t) {
logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight');
// console.warn('sample still loading:', s, n);
return;
}
if (!bufferSource) {
console.warn('no buffer source');
return;
}
bufferSource.playbackRate.value = Math.abs(speed) * bufferSource.playbackRate.value;
if (unit === 'c') {
// are there other units?
bufferSource.playbackRate.value = bufferSource.playbackRate.value * bufferSource.buffer.duration;
}
let duration = soundfont || clip ? hapDuration : bufferSource.buffer.duration / bufferSource.playbackRate.value;
// "The computation of the offset into the sound is performed using the sound buffer's natural sample rate,
// rather than the current playback rate, so even if the sound is playing at twice its normal speed,
// the midway point through a 10-second audio buffer is still 5."
const offset = begin * duration * bufferSource.playbackRate.value;
duration = (end - begin) * duration;
if (loop) {
bufferSource.loop = true;
bufferSource.loopStart = offset;
bufferSource.loopEnd = offset + duration;
duration = loop * duration;
}
t += nudge;
bufferSource.start(t, offset);
if (cut !== undefined) {
cutGroups[cut]?.stop(t); // fade out?
cutGroups[cut] = bufferSource;
}
chain.push(bufferSource);
bufferSource.stop(t + duration + release);
const adsr = getADSR(attack, decay, sustain, release, 1, t, t + duration);
chain.push(adsr);
}
// gain stage
chain.push(gainNode(gain));
// filters
cutoff !== undefined && chain.push(getFilter('lowpass', cutoff, resonance));
hcutoff !== undefined && chain.push(getFilter('highpass', hcutoff, hresonance));
bandf !== undefined && chain.push(getFilter('bandpass', bandf, bandq));
vowel !== undefined && chain.push(ac.createVowelFilter(vowel));
// effects
coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse }));
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape }));
// panning
if (pan !== undefined) {
const panner = ac.createStereoPanner();
panner.pan.value = 2 * pan - 1;
chain.push(panner);
}
// last gain
const post = gainNode(1);
chain.push(post);
post.connect(getDestination());
// delay
let delaySend;
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
const delyNode = getDelay(orbit, delaytime, delayfeedback, t);
delaySend = effectSend(post, delyNode, delay);
}
// reverb
let reverbSend;
if (room > 0 && roomsize > 0) {
const reverbNode = getReverb(orbit, roomsize);
reverbSend = effectSend(post, reverbNode, room);
}
// connect chain elements together
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
// disconnect all nodes when source node has ended:
chain[0].onended = () => chain.concat([delaySend, reverbSend]).forEach((n) => n?.disconnect());
};
export const webaudioOutputTrigger = (t, hap, ct, cps) => webaudioOutput(hap, t - ct, hap.duration / cps);

View File

@ -62,37 +62,34 @@ export function loadWebDirt(config) {
*/
Pattern.prototype.webdirt = function () {
// create a WebDirt object and initialize Web Audio context
return this._withHap((hap) => {
const onTrigger = async (time, e, currentTime) => {
if (!webDirt) {
throw new Error('WebDirt not initialized!');
}
const deadline = time - currentTime;
const { s, n = 0, ...rest } = e.value || {};
if (!s) {
console.warn('Pattern.webdirt: no "s" was set!');
}
const samples = getLoadedSamples();
if (!samples?.[s]) {
// try default samples
webDirt.playSample({ s, n, ...rest }, deadline);
return;
}
if (!samples?.[s]) {
console.warn(`Pattern.webdirt: sample "${s}" not found in loaded samples`, samples);
return this.onTrigger(async (time, e, currentTime) => {
if (!webDirt) {
throw new Error('WebDirt not initialized!');
}
const deadline = time - currentTime;
const { s, n = 0, ...rest } = e.value || {};
if (!s) {
console.warn('Pattern.webdirt: no "s" was set!');
}
const samples = getLoadedSamples();
if (!samples?.[s]) {
// try default samples
webDirt.playSample({ s, n, ...rest }, deadline);
return;
}
if (!samples?.[s]) {
console.warn(`Pattern.webdirt: sample "${s}" not found in loaded samples`, samples);
} else {
const bank = samples[s];
const sampleUrl = bank[n % bank.length];
const buffer = getLoadedBuffer(sampleUrl);
if (!buffer) {
console.log(`Pattern.webdirt: load ${s}:${n} from ${sampleUrl}`);
loadBuffer(sampleUrl, webDirt.ac);
} else {
const bank = samples[s];
const sampleUrl = bank[n % bank.length];
const buffer = getLoadedBuffer(sampleUrl);
if (!buffer) {
console.log(`Pattern.webdirt: load ${s}:${n} from ${sampleUrl}`);
loadBuffer(sampleUrl, webDirt.ac);
} else {
const msg = { buffer: { buffer }, ...rest };
webDirt.playSample(msg, deadline);
}
const msg = { buffer: { buffer }, ...rest };
webDirt.playSample(msg, deadline);
}
};
return hap.setContext({ ...hap.context, onTrigger });
}
});
};

15
repl/package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "@strudel.cycles/repl",
"version": "0.0.0",
"dependencies": {
"@heroicons/react": "^2.0.13",
"@supabase/supabase-js": "^1.35.3",
"nanoid": "^4.0.0",
"react": "^17.0.2",
@ -449,6 +450,14 @@
"node": ">=12"
}
},
"node_modules/@heroicons/react": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.13.tgz",
"integrity": "sha512-iSN5XwmagrnirWlYEWNPdCDj9aRYVD/lnK3JlsC9/+fqGF80k8C7rl+1HCvBX0dBoagKqOFBs6fMhJJ1hOg1EQ==",
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
@ -2972,6 +2981,12 @@
"dev": true,
"optional": true
},
"@heroicons/react": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.13.tgz",
"integrity": "sha512-iSN5XwmagrnirWlYEWNPdCDj9aRYVD/lnK3JlsC9/+fqGF80k8C7rl+1HCvBX0dBoagKqOFBs6fMhJJ1hOg1EQ==",
"requires": {}
},
"@jridgewell/gen-mapping": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",

View File

@ -3,6 +3,7 @@
"private": true,
"version": "0.0.0",
"scripts": {
"predev": "cd ${PWD}/../tutorial/ && npm run render",
"dev": "vite --host",
"start": "vite",
"build": "vite build",
@ -16,6 +17,7 @@
"dbdump": "node src/test/dbdump.js > src/test/dbdump.json"
},
"dependencies": {
"@heroicons/react": "^2.0.13",
"@supabase/supabase-js": "^1.35.3",
"nanoid": "^4.0.0",
"react": "^17.0.2",

View File

@ -18,3 +18,18 @@ body {
background: black;
opacity: 0.5;
}
.cm-gutters {
display: none !important;
}
.cm-content {
padding-top: 12px !important;
padding-left: 8px !important;
}
@layer base {
* {
/* font-family: 'monospace'; */
}
}

View File

@ -4,19 +4,26 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
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/>.
*/
import { evaluate } from '@strudel.cycles/eval';
import { CodeMirror, cx, flash, useHighlighting, useRepl, useWebMidi } from '@strudel.cycles/react';
import { cleanupDraw, cleanupUi, Tone } from '@strudel.cycles/tone';
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import './App.css';
import logo from './logo.svg';
import * as tunes from './tunes.mjs';
import { prebake } from './prebake.mjs';
import * as WebDirt from 'WebDirt';
import { resetLoadedSamples, getAudioContext } from '@strudel.cycles/webaudio';
import { controls, evalScope } from '@strudel.cycles/core';
import { cleanupDraw, cleanupUi, controls, evalScope, logger } from '@strudel.cycles/core';
import { CodeMirror, cx, flash, useHighlighting, useStrudel } from '@strudel.cycles/react';
import {
getAudioContext,
getLoadedSamples,
initAudioOnFirstClick,
resetLoadedSamples,
webaudioOutput,
} from '@strudel.cycles/webaudio';
import { createClient } from '@supabase/supabase-js';
import { nanoid } from 'nanoid';
import React, { createContext, useCallback, useEffect, useState } from 'react';
import * as WebDirt from 'WebDirt';
import './App.css';
import { Footer } from './Footer';
import { Header } from './Header';
import { prebake } from './prebake.mjs';
import * as tunes from './tunes.mjs';
initAudioOnFirstClick();
// Create a single supabase client for interacting with your database
const supabase = createClient(
@ -24,12 +31,9 @@ const supabase = createClient(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpZHhkc3hwaGxoempuem1pZnRoIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTYyMzA1NTYsImV4cCI6MTk3MTgwNjU1Nn0.bqlw7802fsWRnqU5BLYtmXk_k-D1VFmbkHMywWc15NM',
);
evalScope(
Tone,
controls, // sadly, this cannot be exported from core direclty
{ WebDirt },
const modules = [
import('@strudel.cycles/core'),
import('@strudel.cycles/tone'),
// import('@strudel.cycles/tone'),
import('@strudel.cycles/tonal'),
import('@strudel.cycles/mini'),
import('@strudel.cycles/midi'),
@ -38,9 +42,24 @@ evalScope(
import('@strudel.cycles/osc'),
import('@strudel.cycles/serial'),
import('@strudel.cycles/soundfonts'),
];
evalScope(
// Tone,
controls, // sadly, this cannot be exported from core direclty
{ WebDirt },
...modules,
);
prebake();
export let loadedSamples = [];
const presets = prebake();
Promise.all([...modules, presets]).then((data) => {
// console.log('modules and sample registry loade', data);
loadedSamples = Object.entries(getLoadedSamples() || {});
});
const getTime = () => getAudioContext().currentTime;
async function initCode() {
// load code from url hash (either short hash from database or decode long hash)
@ -50,7 +69,6 @@ async function initCode() {
const codeParam = window.location.href.split('#')[1];
// looking like https://strudel.tidalcycles.org/?J01s5i1J0200 (fixed hash length)
if (codeParam) {
console.log('decode hash from url');
// looking like https://strudel.tidalcycles.org/#ImMzIGUzIg%3D%3D (hash length depends on code length)
return atob(decodeURIComponent(codeParam || ''));
} else if (hash) {
@ -74,269 +92,202 @@ async function initCode() {
}
function getRandomTune() {
const allTunes = Object.values(tunes);
const allTunes = Object.entries(tunes);
const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)];
return randomItem(allTunes);
const [name, code] = randomItem(allTunes);
return { name, code };
}
const randomTune = getRandomTune();
const isEmbedded = window.location !== window.parent.location;
const { code: randomTune, name } = getRandomTune();
export const AppContext = createContext();
function App() {
// const [editor, setEditor] = useState();
const [view, setView] = useState();
const [view, setView] = useState(); // codemirror view
const [lastShared, setLastShared] = useState();
const {
setCode,
setPattern,
error,
code,
cycle,
dirty,
log,
togglePlay,
activeCode,
setActiveCode,
activateCode,
pattern,
pushLog,
pending,
hideHeader,
hideConsole,
} = useRepl({
tune: '// LOADING...',
});
const [activeFooter, setActiveFooter] = useState('');
const [isZen, setIsZen] = useState(false);
const [pending, setPending] = useState(false);
const { code, setCode, scheduler, evaluate, activateCode, isDirty, activeCode, pattern, started, stop, error } =
useStrudel({
initialCode: '// LOADING',
defaultOutput: webaudioOutput,
getTime,
autolink: true,
beforeEval: () => {
cleanupUi();
cleanupDraw();
setPending(true);
},
afterEval: () => {
setPending(false);
},
onToggle: (play) => !play && cleanupDraw(false),
});
// init code
useEffect(() => {
initCode().then((decoded) => setCode(decoded || randomTune));
}, []);
const logBox = useRef();
// scroll log box to bottom when log changes
useLayoutEffect(() => {
if (logBox.current) {
logBox.current.scrollTop = logBox.current?.scrollHeight;
}
}, [log]);
// set active pattern on ctrl+enter
useLayoutEffect(() => {
// TODO: make sure this is only fired when editor has focus
const handleKeyPress = async (e) => {
if (e.ctrlKey || e.altKey) {
if (e.code === 'Enter') {
e.preventDefault();
flash(view);
await activateCode();
} else if (e.code === 'Period') {
cycle.stop();
e.preventDefault();
}
initCode().then((decoded) => {
if (!decoded) {
setActiveFooter('intro'); // TODO: get rid
}
};
window.addEventListener('keydown', handleKeyPress, true);
return () => window.removeEventListener('keydown', handleKeyPress, true);
}, [pattern, code, activateCode, cycle, view]);
logger(
`Welcome to Strudel! ${
decoded ? `I have loaded the code from the URL.` : `A random code snippet named "${name}" has been loaded!`
} Press play or hit ctrl+enter to run it!`,
'highlight',
);
setCode(decoded || randomTune);
});
}, []);
// keyboard shortcuts
useKeydown(
useCallback(
async (e) => {
if (e.ctrlKey || e.altKey) {
if (e.code === 'Enter') {
e.preventDefault();
flash(view);
await activateCode();
} else if (e.code === 'Period') {
stop();
e.preventDefault();
}
}
},
[activateCode, stop, view],
),
);
// highlighting
useHighlighting({
view,
pattern,
active: cycle.started && !activeCode?.includes('strudel disable-highlighting'),
getTime: () => Tone.getTransport().seconds,
active: started && !activeCode?.includes('strudel disable-highlighting'),
getTime: () => scheduler.getPhase(),
});
useWebMidi({
ready: useCallback(
({ outputs }) => {
pushLog(`WebMidi ready! Just add .midi(${outputs.map((o) => `'${o.name}'`).join(' | ')}) to the pattern. `);
},
[pushLog],
),
connected: useCallback(
({ outputs }) => {
pushLog(`Midi device connected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`);
},
[pushLog],
),
disconnected: useCallback(
({ outputs }) => {
pushLog(`Midi device disconnected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`);
},
[pushLog],
),
});
//
// UI Actions
//
const handleChangeCode = useCallback(
(c) => {
setCode(c);
started && logger('[edit] code changed. hit ctrl+enter to update');
},
[started],
);
const handleSelectionChange = useCallback((selection) => {
// TODO: scroll to selected function in reference
// console.log('selectino change', selection.ranges[0].from);
}, []);
const handleTogglePlay = async () => {
await getAudioContext().resume(); // fixes no sound in ios webkit
if (!started) {
logger('[repl] started. tip: you can also start by pressing ctrl+enter', 'highlight');
activateCode();
} else {
logger('[repl] stopped. tip: you can also stop by pressing ctrl+dot', 'highlight');
stop();
}
};
const handleUpdate = () => {
isDirty && activateCode();
logger('[repl] code updated! tip: you can also update the code by pressing ctrl+enter', 'highlight');
};
const handleShuffle = async () => {
const { code, name } = getRandomTune();
logger(`[repl] ✨ loading random tune "${name}"`);
resetLoadedSamples();
await prebake(); // declare default samples
await evaluate(code, false);
};
const handleShare = async () => {
const codeToShare = activeCode || code;
if (lastShared === codeToShare) {
logger(`Link already generated!`, 'error');
return;
}
// generate uuid in the browser
const hash = nanoid(12);
const { data, error } = await supabase.from('code').insert([{ code: codeToShare, hash }]);
if (!error) {
setLastShared(activeCode || code);
const shareUrl = window.location.origin + '?' + hash;
// copy shareUrl to clipboard
navigator.clipboard.writeText(shareUrl);
const message = `Link copied to clipboard: ${shareUrl}`;
alert(message);
// alert(message);
logger(message, 'highlight');
} else {
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
logger(message);
}
};
return (
<div className="min-h-screen flex flex-col">
{!hideHeader && (
<header
id="header"
className={cx(
'flex-none w-full px-2 flex border-b border-gray-200 justify-between z-[10] bg-gray-100',
isEmbedded ? 'h-8' : 'h-14',
)}
>
<div className="flex items-center space-x-2">
<img src={logo} className={cx('Tidal-logo', isEmbedded ? 'w-6 h-6' : 'w-10 h-10')} alt="logo" />
<h1 className={isEmbedded ? 'text-l' : 'text-xl'}>Strudel {isEmbedded ? 'Mini ' : ''}REPL</h1>
</div>
<div className="flex">
<button
onClick={async () => {
await getAudioContext().resume(); // fixes no sound in ios webkit
togglePlay();
}}
className={cx('hover:bg-gray-300', !isEmbedded ? 'p-2' : 'px-2')}
>
{!pending ? (
<span className={cx('flex items-center', isEmbedded ? 'w-16' : 'w-16')}>
{cycle.started ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
clipRule="evenodd"
/>
</svg>
)}
{cycle.started ? 'pause' : 'play'}
</span>
) : (
<>loading...</>
)}
</button>
<button
onClick={() => {
dirty && activateCode();
pushLog('Code updated! Tip: You can also update the code by pressing ctrl+enter.');
}}
className={cx(
'hover:bg-gray-300',
!isEmbedded ? 'p-2' : 'px-2',
!dirty || !activeCode ? 'opacity-50' : '',
)}
>
🔄 update
</button>
{!isEmbedded && (
<button
className="hover:bg-gray-300 p-2"
onClick={async () => {
const _code = getRandomTune();
// console.log('tune', _code); // uncomment this to debug when random code fails
setCode(_code);
cleanupDraw();
cleanupUi();
resetLoadedSamples();
await prebake(); // declare default samples
const parsed = await evaluate(_code);
setPattern(parsed.pattern);
setActiveCode(_code);
}}
>
🎲 random
</button>
)}
{!isEmbedded && (
<button className={cx('hover:bg-gray-300', !isEmbedded ? 'p-2' : 'px-2')}>
<a href="./tutorial">📚 tutorial</a>
</button>
)}
{!isEmbedded && (
<button
className={cx('cursor-pointer hover:bg-gray-300', !isEmbedded ? 'p-2' : 'px-2')}
onClick={async () => {
const codeToShare = activeCode || code;
if (lastShared === codeToShare) {
// alert('Link already generated!');
pushLog(`Link already generated!`);
return;
}
// generate uuid in the browser
const hash = nanoid(12);
const { data, error } = await supabase.from('code').insert([{ code: codeToShare, hash }]);
if (!error) {
setLastShared(activeCode || code);
const shareUrl = window.location.origin + '?' + hash;
// copy shareUrl to clipboard
navigator.clipboard.writeText(shareUrl);
const message = `Link copied to clipboard: ${shareUrl}`;
// alert(message);
pushLog(message);
} else {
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
pushLog(message);
}
}}
>
📣 share{lastShared && lastShared === (activeCode || code) ? 'd!' : ''}
</button>
)}
{isEmbedded && (
<button className={cx('hover:bg-gray-300 px-2')}>
<a href={window.location.href} target="_blank" rel="noopener noreferrer" title="Open in REPL">
🚀 open
</a>
</button>
)}
{isEmbedded && (
<button className={cx('hover:bg-gray-300 px-2')}>
<a
onClick={() => {
window.location.href = initialUrl;
window.location.reload();
}}
title="Reset"
>
💔 reset
</a>
</button>
)}
</div>
</header>
)}
<section className="grow flex flex-col text-gray-100">
<div className="grow relative flex overflow-auto pb-8 cursor-text" id="code">
{/* onCursor={markParens} */}
<CodeMirror value={code} onChange={setCode} onViewChanged={setView} />
<span className="z-[20] bg-black rounded-t-md py-1 px-2 fixed bottom-0 right-1 text-xs whitespace-pre text-right pointer-events-none">
{!cycle.started ? `press ctrl+enter to play\n` : dirty ? `ctrl+enter to update\n` : 'no changes\n'}
</span>
{error && (
<div
className={cx(
'rounded-md fixed pointer-events-none left-2 bottom-1 text-xs bg-black px-2 z-[20]',
'text-red-500',
)}
>
{error?.message || 'unknown error'}
</div>
)}
</div>
{!isEmbedded && !hideConsole && (
<textarea
className="z-[10] h-16 border-0 text-xs bg-[transparent] border-t border-slate-600 resize-none"
value={log}
readOnly
ref={logBox}
style={{ fontFamily: 'monospace' }}
/>
// bg-gradient-to-t from-blue-900 to-slate-900
// bg-gradient-to-t from-green-900 to-slate-900
<AppContext.Provider
value={{
started,
pending,
isDirty,
lastShared,
activeCode,
activeFooter,
setActiveFooter,
handleChangeCode,
handleTogglePlay,
handleUpdate,
handleShuffle,
handleShare,
isZen,
setIsZen,
}}
>
<div
className={cx(
'h-screen flex flex-col',
// 'bg-gradient-to-t from-green-900 to-slate-900', //
)}
</section>
{/* !isEmbedded && (
<button className="fixed right-4 bottom-2 z-[11]" onClick={() => playStatic(code)}>
static
</button>
) */}
</div>
>
<Header />
<section className="grow flex text-gray-100 relative overflow-auto cursor-text pb-0" id="code">
<CodeMirror
value={code}
onChange={handleChangeCode}
onViewChanged={setView}
onSelectionChange={handleSelectionChange}
/>
</section>
{error && (
<div className="text-red-500 p-4 bg-lineblack animate-pulse">{error.message || 'Unknown Error :-/'}</div>
)}
<Footer />
</div>
</AppContext.Provider>
);
}
export default App;
export function useEvent(name, onTrigger, useCapture = false) {
useEffect(() => {
document.addEventListener(name, onTrigger, useCapture);
return () => {
document.removeEventListener(name, onTrigger, useCapture);
};
}, [onTrigger]);
}
function useKeydown(onTrigger) {
useEvent('keydown', onTrigger, true);
}

196
repl/src/Footer.jsx Normal file
View File

@ -0,0 +1,196 @@
import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon';
import { logger } from '@strudel.cycles/core';
import { cx } from '@strudel.cycles/react';
import { nanoid } from 'nanoid';
import React, { useContext, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useEvent, loadedSamples, AppContext } from './App';
import { Reference } from './Reference';
export function Footer() {
// const [activeFooter, setActiveFooter] = useState('console');
const { activeFooter, setActiveFooter, isZen } = useContext(AppContext);
const footerContent = useRef();
const [log, setLog] = useState([]);
useLayoutEffect(() => {
if (footerContent.current && activeFooter === 'console') {
// scroll log box to bottom when log changes
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
}
}, [log, activeFooter]);
useLayoutEffect(() => {
if (!footerContent.current) {
} else if (activeFooter === 'console') {
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
} else {
footerContent.current.scrollTop = 0;
}
}, [activeFooter]);
useLogger(
useCallback((e) => {
const { message, type, data } = e.detail;
setLog((l) => {
const lastLog = l.length ? l[l.length - 1] : undefined;
const id = nanoid(12);
// if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) {
if (type === 'loaded-sample') {
// const loadIndex = l.length - 1;
const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
l[loadIndex] = { message, type, id, data };
} else if (lastLog && lastLog.message === message) {
l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]);
} else {
l = l.concat([{ message, type, id, data }]);
}
return l.slice(-20);
});
}, []),
);
const FooterTab = ({ children, name, label }) => (
<>
<div
onClick={() => setActiveFooter(name)}
className={cx(
'h-8 px-2 text-white cursor-pointer hover:text-tertiary flex items-center space-x-1 border-b',
activeFooter === name ? 'border-white hover:border-tertiary' : 'border-transparent',
)}
>
{label || name}
</div>
{activeFooter === name && <>{children}</>}
</>
);
if (isZen) {
return null;
}
return (
<footer className="bg-footer z-[20]">
<div className="flex justify-between px-2">
<div className={cx('flex select-none', activeFooter && 'pb-2')}>
<FooterTab name="intro" label="welcome" />
<FooterTab name="samples" />
<FooterTab name="console" />
<FooterTab name="reference" />
</div>
{activeFooter !== '' && (
<button onClick={() => setActiveFooter('')} className="text-white">
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
{activeFooter !== '' && (
<div
className="text-white font-mono text-sm h-[360px] flex-none overflow-auto max-w-full relative"
ref={footerContent}
>
{activeFooter === 'intro' && (
<div className="prose prose-invert max-w-[600px] pt-2 font-sans pb-8 px-4">
<h3>
<span className={cx('animate-spin inline-block select-none')}>🌀</span> welcome
</h3>
<p>
You have found <span className="underline">strudel</span>, a new live coding platform to write dynamic
music pieces in the browser! It is free and open-source and made for beginners and experts alike. To get
started:
<br />
<br />
<span className="underline">1. hit play</span> - <span className="underline">2. change something</span>{' '}
- <span className="underline">3. hit update</span>
<br />
If you don't like what you hear, try <span className="underline">shuffle</span>!
</p>
<p>
To learn more about what this all means, check out the{' '}
<a href="https://strudel.tidalcycles.org/tutorial" target="_blank">
interactive tutorial
</a>
. Also feel free to join the{' '}
<a href="https://discord.com/invite/HGEdXmRkzT" target="_blank">
tidalcycles discord channel
</a>{' '}
to ask any questions, give feedback or just say hello.
</p>
<h3>about</h3>
<p>
strudel is a JavaScript version of{' '}
<a href="tidalcycles.org/" target="_blank">
tidalcycles
</a>
, which is a popular live coding language for music, written in Haskell. You can find the source code at{' '}
<a href="https://github.com/tidalcycles/strudel" target="_blank">
github
</a>
. Please consider to{' '}
<a href="https://opencollective.com/tidalcycles" target="_blank">
support this project
</a>{' '}
to ensure ongoing development 💖
</p>
</div>
)}
{activeFooter === 'console' && (
<div className="break-all px-4">
{log.map((l, i) => {
const message = linkify(l.message);
return (
<div
key={l.id}
className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'text-highlight')}
>
<span dangerouslySetInnerHTML={{ __html: message }} />
{l.count ? ` (${l.count})` : ''}
</div>
);
})}
</div>
)}
{activeFooter === 'samples' && (
<div className="break-normal w-full px-4">
<span className="text-white">{loadedSamples.length} banks loaded:</span>
{loadedSamples.map(([name, samples]) => (
<span key={name} className="cursor-pointer hover:text-tertiary" onClick={() => {}}>
{' '}
{name}(
{Array.isArray(samples)
? samples.length
: typeof samples === 'object'
? Object.values(samples).length
: 1}
){' '}
</span>
))}
</div>
)}
{activeFooter === 'reference' && <Reference />}
</div>
)}
</footer>
);
}
function useLogger(onTrigger) {
useEvent(logger.key, onTrigger);
}
function linkify(inputText) {
var replacedText, replacePattern1, replacePattern2, replacePattern3;
//URLs starting with http://, https://, or ftp://
replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
replacedText = inputText.replace(replacePattern1, '<a class="underline" href="$1" target="_blank">$1</a>');
//URLs starting with "www." (without // before it, or it'd re-link the ones done above).
replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
replacedText = replacedText.replace(
replacePattern2,
'$1<a class="underline" href="http://$2" target="_blank">$2</a>',
);
//Change email addresses to mailto:: links.
replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
replacedText = replacedText.replace(replacePattern3, '<a class="underline" href="mailto:$1">$1</a>');
return replacedText;
}

140
repl/src/Header.jsx Normal file
View File

@ -0,0 +1,140 @@
import AcademicCapIcon from '@heroicons/react/20/solid/AcademicCapIcon';
import CommandLineIcon from '@heroicons/react/20/solid/CommandLineIcon';
import LinkIcon from '@heroicons/react/20/solid/LinkIcon';
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import SparklesIcon from '@heroicons/react/20/solid/SparklesIcon';
import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon';
import { cx } from '@strudel.cycles/react';
import React, { useContext } from 'react';
import { AppContext } from './App';
import './App.css';
const isEmbedded = window.location !== window.parent.location;
export function Header() {
const {
started,
pending,
isDirty,
lastShared,
activeCode,
handleTogglePlay,
handleUpdate,
handleShuffle,
handleShare,
isZen,
setIsZen,
} = useContext(AppContext);
return (
<header
id="header"
className={cx(
'flex-none w-full md:flex text-black justify-between z-[100] text-lg select-none sticky top-0',
isEmbedded ? 'h-12 md:h-8' : 'h-25 md:h-14',
!isZen && 'bg-header',
)}
>
<div className="px-4 flex items-center space-x-2 pt-2 md:pt-0 select-none">
{/* <img
src={logo}
className={cx('Tidal-logo', isEmbedded ? 'w-8 h-8' : 'w-10 h-10', started && 'animate-pulse')} // 'bg-[#ffffff80] rounded-full'
alt="logo"
/> */}
<h1
className={cx(
isEmbedded ? 'text-l' : 'text-xl',
// 'bg-clip-text bg-gradient-to-r from-primary to-secondary text-transparent font-bold',
'text-white font-bold flex space-x-2',
)}
>
<div
className={cx('mt-[1px]', started && 'animate-spin', 'cursor-pointer')}
onClick={() => setIsZen((z) => !z)}
>
🌀
</div>
{!isZen && (
<div className={cx(started && 'animate-pulse')}>
<span className="">strudel</span> <span className="text-sm">REPL</span>
</div>
)}
</h1>
</div>
{!isZen && (
<div className="flex max-w-full overflow-auto text-white ">
<button
onClick={handleTogglePlay}
className={cx(!isEmbedded ? 'p-2' : 'px-2', 'hover:text-tertiary', !started && 'animate-pulse')}
>
{!pending ? (
<span className={cx('flex items-center space-x-1', isEmbedded ? 'w-16' : 'w-16')}>
{started ? <StopCircleIcon className="w-5 h-5" /> : <PlayCircleIcon className="w-5 h-5" />}
<span>{started ? 'stop' : 'play'}</span>
</span>
) : (
<>loading...</>
)}
</button>
<button
onClick={handleUpdate}
className={cx(
'flex items-center space-x-1',
!isEmbedded ? 'p-2' : 'px-2',
!isDirty || !activeCode ? 'opacity-50' : 'hover:text-tertiary',
)}
>
<CommandLineIcon className="w-5 h-5" />
<span>update</span>
</button>
{!isEmbedded && (
<button className="hover:text-tertiary p-2 flex items-center space-x-1" onClick={handleShuffle}>
<SparklesIcon className="w-5 h-5" />
<span> shuffle</span>
</button>
)}
{!isEmbedded && (
<a
href="./tutorial"
className={cx('hover:text-tertiary flex items-center space-x-1', !isEmbedded ? 'p-2' : 'px-2')}
>
<AcademicCapIcon className="w-5 h-5" />
<span>learn</span>
</a>
)}
{!isEmbedded && (
<button
className={cx(
'cursor-pointer hover:text-tertiary flex items-center space-x-1',
!isEmbedded ? 'p-2' : 'px-2',
)}
onClick={handleShare}
>
<LinkIcon className="w-5 h-5" />
<span>share{lastShared && lastShared === (activeCode || code) ? 'd!' : ''}</span>
</button>
)}
{isEmbedded && (
<button className={cx('hover:text-tertiary px-2')}>
<a href={window.location.href} target="_blank" rel="noopener noreferrer" title="Open in REPL">
🚀 open
</a>
</button>
)}
{isEmbedded && (
<button className={cx('hover:text-tertiary px-2')}>
<a
onClick={() => {
window.location.href = initialUrl;
window.location.reload();
}}
title="Reset"
>
💔 reset
</a>
</button>
)}
</div>
)}
</header>
);
}

38
repl/src/Reference.jsx Normal file
View File

@ -0,0 +1,38 @@
import jsdocJson from '../../doc.json';
const visibleFunctions = jsdocJson.docs
.filter(({ name, description }) => name && !name.startsWith('_') && !!description)
.sort((a, b) => a.meta.filename.localeCompare(b.meta.filename) + a.name.localeCompare(b.name));
export function Reference() {
return (
<div className="flex h-full w-full pt-2">
<div className="w-64 flex-none h-full overflow-y-auto overflow-x-hidden pr-4">
{visibleFunctions.map((entry, i) => (
<a key={i} className="cursor-pointer block hover:bg-linegray py-1 px-4" href={`#doc-${i}`}>
{entry.name} {/* <span className="text-gray-600">{entry.meta.filename}</span> */}
</a>
))}
</div>
<div className="break-normal w-full h-full overflow-auto pl-4 flex relative">
<div className="prose prose-invert">
<h2>API Reference</h2>
<p>
This is the long list functions you can use! Remember that you don't need to remember all of those and that
you can already make music with a small set of functions!
</p>
{visibleFunctions.map((entry, i) => (
<section key={i}>
<h3 id={`doc-${i}`}>{entry.name}</h3>
{/* <small>{entry.meta.filename}</small> */}
<p dangerouslySetInnerHTML={{ __html: entry.description }}></p>
{entry.examples?.map((example, j) => (
<pre key={j}>{example}</pre>
))}
</section>
))}
</div>
</div>
</div>
);
}

View File

@ -5,13 +5,15 @@ export async function prebake({ isMock = false, baseDir = '.' } = {}) {
if (!isMock) {
// https://archive.org/details/SalamanderGrandPianoV3
// License: CC-by http://creativecommons.org/licenses/by/3.0/ Author: Alexander Holm
samples('piano.json', `${baseDir}/piano/`);
// https://github.com/sgossner/VCSL/
// https://api.github.com/repositories/126427031/contents/
// LICENSE: CC0 general-purpose
samples('vcsl.json', 'github:sgossner/VCSL/master/');
samples('tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/');
samples('EmuSP12.json', `${baseDir}/EmuSP12/`);
return await Promise.all([
samples('piano.json', `${baseDir}/piano/`),
// https://github.com/sgossner/VCSL/
// https://api.github.com/repositories/126427031/contents/
// LICENSE: CC0 general-purpose
samples('vcsl.json', 'github:sgossner/VCSL/master/'),
samples('tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/'),
samples('EmuSP12.json', `${baseDir}/EmuSP12/`),
]);
}
}

View File

@ -3,8 +3,8 @@
// it might require mocking more stuff when tunes added that use other functions
// import * as tunes from './tunes.mjs';
import { evaluate } from '@strudel.cycles/eval';
// import { evaluate } from '@strudel.cycles/transpiler';
// import { evaluate } from '@strudel.cycles/eval';
import { evaluate } from '@strudel.cycles/transpiler';
import { evalScope } from '@strudel.cycles/core';
import * as strudel from '@strudel.cycles/core';
import * as webaudio from '@strudel.cycles/webaudio';

View File

@ -8335,6 +8335,21 @@ exports[`renders tunes > tune: hyperpop 1`] = `
]
`;
exports[`renders tunes > tune: juxUndTollerei 1`] = `
[
"3/4 -> 1/1: {\\"note\\":\\"c3\\",\\"s\\":\\"sawtooth\\",\\"pan\\":0,\\"cutoff\\":1670.953955747281,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
"1/2 -> 3/4: {\\"note\\":\\"eb3\\",\\"s\\":\\"sawtooth\\",\\"pan\\":0,\\"cutoff\\":1524.257063143398,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
"1/4 -> 1/2: {\\"note\\":\\"g3\\",\\"s\\":\\"sawtooth\\",\\"pan\\":0,\\"cutoff\\":1361.2562095290161,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
"0/1 -> 1/4: {\\"note\\":\\"bb3\\",\\"s\\":\\"sawtooth\\",\\"pan\\":0,\\"cutoff\\":1188.2154262966046,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
"0/1 -> 1/4: {\\"note\\":\\"c3\\",\\"s\\":\\"sawtooth\\",\\"pan\\":1,\\"cutoff\\":1188.2154262966046,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
"1/4 -> 1/2: {\\"note\\":\\"eb3\\",\\"s\\":\\"sawtooth\\",\\"pan\\":1,\\"cutoff\\":1361.2562095290161,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
"1/2 -> 3/4: {\\"note\\":\\"g3\\",\\"s\\":\\"sawtooth\\",\\"pan\\":1,\\"cutoff\\":1524.257063143398,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
"3/4 -> 1/1: {\\"note\\":\\"bb3\\",\\"s\\":\\"sawtooth\\",\\"pan\\":1,\\"cutoff\\":1670.953955747281,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
"101/200 -> 201/200: {\\"note\\":65,\\"s\\":\\"triangle\\",\\"pan\\":0,\\"cutoff\\":1601.4815730092653,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
"101/200 -> 201/200: {\\"note\\":55,\\"s\\":\\"triangle\\",\\"pan\\":1,\\"cutoff\\":1601.4815730092653,\\"decay\\":0.05,\\"sustain\\":0,\\"room\\":0.6,\\"delay\\":0.5,\\"delaytime\\":0.1,\\"delayfeedback\\":0.4}",
]
`;
exports[`renders tunes > tune: meltingsubmarine 1`] = `
[
"0/1 -> 3/2: {\\"s\\":\\"bd\\",\\"speed\\":0.7519542165100574}",

View File

@ -308,7 +308,6 @@ export const blippyRhodes = `samples({
}
}, 'https://loophole-letters.vercel.app/')
const bass = synth(osc('sawtooth8')).chain(vol(.5),out())
const scales = cat('C major', 'C mixolydian', 'F lydian', ['F minor', cat('Db major','Db mixolydian')])
stack(
@ -950,7 +949,7 @@ export const flatrave = `stack(
.decay(.05).sustain(0).delay(.2).degradeBy(.5).mask("<0 1>/16")
)`;
export const amensister = `samples('github:tidalcycles/Dirt-Samples/master')
export const amensister = `await samples('github:tidalcycles/Dirt-Samples/master')
stack(
// amen
@ -981,3 +980,14 @@ stack(
,
n("0 1").s("east").delay(.5).degradeBy(.8).speed(rand.range(.5,1.5))
).reset("<x@7 x(5,8)>")`;
export const juxUndTollerei = `note("c3 eb3 g3 bb3").palindrome()
.s('sawtooth')
.jux(x=>x.rev().color('green').s('sawtooth'))
.off(1/4, x=>x.add(note("<7 12>/2")).slow(2).late(.005).s('triangle'))
//.delay(.5)
.fast(1).cutoff(sine.range(200,2000).slow(8))
.decay(.05).sustain(0)
.room(.6)
.delay(.5).delaytime(.1).delayfeedback(.4)
.pianoroll()`;

View File

@ -6,9 +6,24 @@ This program is free software: you can redistribute it and/or modify it under th
module.exports = {
// TODO: find out if leaving out tutorial path works now
content: ['./src/**/*.{js,jsx,ts,tsx}','./tutorial/**/*.{js,jsx,ts,tsx}'],
content: ['./src/**/*.{js,jsx,ts,tsx}', './tutorial/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
extend: {
colors: {
primary: '#c792ea',
secondary: '#c3e88d',
tertiary: '#82aaff',
highlight: '#ffcc00',
linegray: '#8a91991a',
lineblack: '#00000095',
bg: '#222222',
// header: '#8a91991a',
// footer: '#8a91991a',
header: '#00000050',
// header: 'transparent',
footer: '#00000050',
},
},
},
plugins: [require('@tailwindcss/typography')],
};

View File

@ -7,7 +7,7 @@ export default defineConfig({
plugins: [react()],
build: {
outDir: '../out',
sourcemap: false,
sourcemap: true,
rollupOptions: {
plugins: [visualizer({ template: 'treemap' })],
},

View File

@ -10,7 +10,7 @@ fetch('https://strudel.tidalcycles.org/EmuSP12.json')
evalScope(
controls,
import('@strudel.cycles/core'),
import('@strudel.cycles/tone'),
// import('@strudel.cycles/tone'),
import('@strudel.cycles/tonal'),
import('@strudel.cycles/mini'),
import('@strudel.cycles/midi'),