start packaging

This commit is contained in:
Felix Roos 2022-03-25 14:39:25 +01:00
parent a19d789145
commit 6f60a3a1d5
98 changed files with 11162 additions and 11122 deletions

6
packages/repl-react/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.snowpack
build
node_modules
.DS_Store
.parcel-cache
oldtunes.ts

179
packages/repl-react/App.jsx Normal file
View file

@ -0,0 +1,179 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import CodeMirror, { markEvent, markParens } from './CodeMirror';
import cx from './cx';
import { evaluate } from '../eval/evaluate.mjs';
// import logo from './logo.svg';
import playStatic from './static.mjs';
import { defaultSynth } from '../tone/tone.mjs';
import * as tunes from '../tunes/tunes';
import useRepl from '../../repl/src/useRepl';
import { useWebMidi } from './useWebMidi';
// TODO: use https://www.npmjs.com/package/@monaco-editor/react
const [_, codeParam] = window.location.href.split('#');
let decoded;
try {
decoded = atob(decodeURIComponent(codeParam || ''));
} catch (err) {
console.warn('failed to decode', err);
}
function getRandomTune() {
const allTunes = Object.values(tunes);
const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)];
return randomItem(allTunes);
}
const randomTune = getRandomTune();
function App() {
const [editor, setEditor] = useState();
const { setCode, setPattern, error, code, cycle, dirty, log, togglePlay, activateCode, pattern, pushLog, pending } =
useRepl({
tune: decoded || randomTune,
defaultSynth,
onDraw: useCallback(markEvent(editor), [editor]),
});
const [uiHidden, setUiHidden] = useState(false);
const logBox = useRef();
// scroll log box to bottom when log changes
useLayoutEffect(() => {
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) {
switch (e.code) {
case 'Enter':
await activateCode();
break;
case 'Period':
cycle.stop();
}
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [pattern, code]);
useWebMidi({
ready: useCallback(({ outputs }) => {
pushLog(`WebMidi ready! Just add .midi(${outputs.map((o) => `'${o.name}'`).join(' | ')}) to the pattern. `);
}, []),
connected: useCallback(({ outputs }) => {
pushLog(`Midi device connected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`);
}, []),
disconnected: useCallback(({ outputs }) => {
pushLog(`Midi device disconnected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`);
}, []),
});
return (
<div className="min-h-screen flex flex-col">
<header
id="header"
className={cx(
'flex-none w-full h-14 px-2 flex border-b border-gray-200 justify-between z-[10]',
uiHidden ? 'bg-transparent text-white' : 'bg-white',
)}
>
<div className="flex items-center space-x-2">
{/* <img src={logo} className="Tidal-logo w-12 h-12" alt="logo" /> */}
<h1 className="text-2xl">Strudel REPL</h1>
</div>
<div className="flex space-x-4">
<button onClick={() => togglePlay()}>
{!pending ? (
<span className="flex items-center 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={async () => {
const _code = getRandomTune();
console.log('tune', _code); // uncomment this to debug when random code fails
setCode(_code);
const parsed = await evaluate(_code);
setPattern(parsed.pattern);
}}
>
🎲 random
</button>
<button>
<a href="./tutorial">📚 tutorial</a>
</button>
<button onClick={() => setUiHidden((c) => !c)}>👀 {uiHidden ? 'show ui' : 'hide ui'}</button>
</div>
</header>
<section className="grow flex flex-col text-gray-100">
<div className="grow relative" id="code">
<div
className={cx(
'h-full transition-opacity',
error ? 'focus:ring-red-500' : 'focus:ring-slate-800',
uiHidden ? 'opacity-0' : 'opacity-100',
)}
>
<CodeMirror
value={code}
editorDidMount={setEditor}
options={{
mode: 'javascript',
theme: 'material',
lineNumbers: false,
styleSelectedText: true,
cursorBlinkRate: 0,
}}
onCursor={markParens}
onChange={(_, __, value) => setCode(value)}
/>
<span className="p-4 absolute top-0 right-0 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>
</div>
{error && (
<div className={cx('absolute right-2 bottom-2 px-2', 'text-red-500')}>
{error?.message || 'unknown error'}
</div>
)}
</div>
<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' }}
/>
</section>
<button className="fixed right-4 bottom-2 z-[11]" onClick={() => playStatic(code)}>
static
</button>
</div>
);
}
export default App;

View file

@ -0,0 +1,121 @@
import React from 'react';
import { Controlled as CodeMirror2 } from 'react-codemirror2';
import 'codemirror/mode/javascript/javascript.js';
import 'codemirror/mode/pegjs/pegjs.js';
// import 'codemirror/theme/material.css';
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/material.css';
export default function CodeMirror({ value, onChange, onCursor, options, editorDidMount }) {
options = options || {
mode: 'javascript',
theme: 'material',
lineNumbers: true,
styleSelectedText: true,
cursorBlinkRate: 500,
};
return (
<CodeMirror2
value={value}
options={options}
onBeforeChange={onChange}
editorDidMount={editorDidMount}
onCursor={(editor, data) => onCursor?.(editor, data)}
/>
);
}
export const markEvent = (editor) => (time, event) => {
const locs = event.context.locations;
if (!locs || !editor) {
return;
}
const col = event.context?.color || '#FFCA28';
// mark active event
const marks = locs.map(({ start, end }) =>
editor.getDoc().markText(
{ line: start.line - 1, ch: start.column },
{ line: end.line - 1, ch: end.column },
//{ css: 'background-color: #FFCA28; color: black' } // background-color is now used by parent marking
{ css: 'outline: 1px solid ' + col + '; box-sizing:border-box' },
//{ css: `background-color: ${col};border-radius:5px` },
),
);
//Tone.Transport.schedule(() => { // problem: this can be cleared by scheduler...
setTimeout(() => {
marks.forEach((mark) => mark.clear());
// }, '+' + event.duration * 0.5);
}, event.duration /* * 0.9 */ * 1000);
};
let parenMark;
export const markParens = (editor, data) => {
const v = editor.getDoc().getValue();
const marked = getCurrentParenArea(v, data);
parenMark?.clear();
parenMark = editor.getDoc().markText(...marked, { css: 'background-color: #00007720' }); //
};
// returns { line, ch } from absolute character offset
export function offsetToPosition(offset, code) {
const lines = code.split('\n');
let line = 0;
let ch = 0;
for (let i = 0; i < offset; i++) {
if (ch === lines[line].length) {
line++;
ch = 0;
} else {
ch++;
}
}
return { line, ch };
}
// returns absolute character offset from { line, ch }
export function positionToOffset(position, code) {
const lines = code.split('\n');
let offset = 0;
for (let i = 0; i < position.line; i++) {
offset += lines[i].length + 1;
}
offset += position.ch;
return offset;
}
// given code and caret position, the functions returns the indices of the parens we are in
export function getCurrentParenArea(code, caretPosition) {
const caret = positionToOffset(caretPosition, code);
let open, i, begin, end;
// walk left
i = caret;
open = 0;
while (i > 0) {
if (code[i - 1] === '(') {
open--;
} else if (code[i - 1] === ')') {
open++;
}
if (open === -1) {
break;
}
i--;
}
begin = i;
// walk right
i = caret;
open = 0;
while (i < code.length) {
if (code[i] === '(') {
open--;
} else if (code[i] === ')') {
open++;
}
if (open === 1) {
break;
}
i++;
}
end = i;
return [begin, end].map((o) => offsetToPosition(o, code));
}

View file

@ -0,0 +1,22 @@
# Strudel REPL
## Default Snowpack Readme
> ✨ Bootstrapped with Create Snowpack App (CSA).
## Available Scripts
### npm start
Runs the app in the development mode.
Open http://localhost:8080 to view it in the browser.
The page will reload if you make edits.
You will also see any lint errors in the console.
### npm run build
Builds a static copy of your site to the `docs/` folder.
Your app is ready to be deployed!
**For the best production performance:** Add a build bundler plugin like "@snowpack/plugin-webpack" to your `snowpack.config.mjs` config file.

View file

@ -0,0 +1,3 @@
export default function cx(...classes) { // : Array<string | undefined>
return classes.filter(Boolean).join(' ');
}

View file

@ -0,0 +1,53 @@
import * as Tone from 'tone';
import { Pattern } from '../core/strudel.mjs';
export const getDrawContext = (id = 'test-canvas') => {
let canvas = document.querySelector('#' + id);
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = id;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style = 'pointer-events:none;width:100%;height:100%;position:fixed;top:0;left:0;z-index:5';
document.body.prepend(canvas);
}
return canvas.getContext('2d');
};
Pattern.prototype.draw = function (callback, cycleSpan, lookaheadCycles = 1) {
if (window.strudelAnimation) {
cancelAnimationFrame(window.strudelAnimation);
}
const ctx = getDrawContext();
let cycle,
events = [];
const animate = (time) => {
const t = Tone.getTransport().seconds;
if (cycleSpan) {
const currentCycle = Math.floor(t / cycleSpan);
if (cycle !== currentCycle) {
cycle = currentCycle;
const begin = currentCycle * cycleSpan;
const end = (currentCycle + lookaheadCycles) * cycleSpan;
events = this._asNumber(true) // true = silent error
.query(new State(new TimeSpan(begin, end)))
.filter((event) => event.part.begin.equals(event.whole.begin));
}
}
callback(ctx, events, t, cycleSpan, time);
window.strudelAnimation = requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
return this;
};
export const cleanup = () => {
const ctx = getDrawContext();
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
if (window.strudelAnimation) {
cancelAnimationFrame(window.strudelAnimation);
}
if (window.strudelScheduler) {
clearInterval(window.strudelScheduler);
}
};

View file

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// Hot Module Replacement (HMR) - Remove this snippet to remove HMR.
// Learn more: https://snowpack.dev/concepts/hot-module-replacement
if (import.meta.hot) {
import.meta.hot.accept();
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

21647
packages/repl-react/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,61 @@
{
"scripts": {
"start": "snowpack dev --polyfill-node",
"build": "snowpack build --polyfill-node && cp ./public/.nojekyll ../docs && npm run build-tutorial",
"static": "npx serve ../docs",
"test": "web-test-runner \"src/**/*.test.tsx\"",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"",
"peggy": "peggy -o krill-parser.js --format es ./krill.pegjs",
"tutorial": "parcel src/tutorial/index.html --no-cache",
"build-tutorial": "parcel build src/tutorial/index.html --dist-dir ../docs/tutorial --public-url /tutorial --no-optimize --no-scope-hoist --no-cache"
},
"dependencies": {
"@tonaljs/tonal": "^4.6.5",
"@tonejs/piano": "^0.2.1",
"bjork": "^0.0.1",
"chord-voicings": "^0.0.1",
"codemirror": "^5.65.1",
"estraverse": "^5.3.0",
"events": "^3.3.0",
"multimap": "^1.1.0",
"ramda": "^0.28.0",
"react": "^17.0.2",
"react-codemirror2": "^7.2.1",
"react-dom": "^17.0.2",
"shift-ast": "^6.1.0",
"shift-codegen": "^7.0.3",
"shift-regexp-acceptor": "^2.0.3",
"shift-spec": "^2018.0.2",
"tone": "^14.7.77",
"tune.js": "^1.0.1",
"webmidi": "^2.5.2"
},
"devDependencies": {
"@mdx-js/react": "^1.6.22",
"@parcel/transformer-mdx": "^2.3.1",
"@snowpack/plugin-dotenv": "^2.1.0",
"@snowpack/plugin-postcss": "^1.4.3",
"@snowpack/plugin-react-refresh": "^2.5.0",
"@snowpack/plugin-typescript": "^1.2.1",
"@snowpack/web-test-runner-plugin": "^0.2.2",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.2",
"@testing-library/react": "^11.2.6",
"@types/chai": "^4.2.17",
"@types/mocha": "^8.2.2",
"@types/react": "^17.0.4",
"@types/react-dom": "^17.0.3",
"@types/snowpack-env": "^2.3.3",
"@web/test-runner": "^0.13.3",
"autoprefixer": "^10.4.2",
"chai": "^4.3.4",
"parcel": "^2.3.1",
"peggy": "^1.2.0",
"postcss": "^8.4.6",
"prettier": "^2.2.1",
"snowpack": "^3.3.7",
"tailwindcss": "^3.0.18",
"typescript": "^4.2.4"
}
}

View file

@ -0,0 +1,38 @@
import { Pattern } from '../core/strudel.mjs';
Pattern.prototype.pianoroll = function ({
timeframe = 10,
inactive = '#C9E597',
active = '#FFCA28',
background = '#2A3236',
maxMidi = 90,
minMidi = 0,
} = {}) {
const w = window.innerWidth;
const h = window.innerHeight;
const midiRange = maxMidi - minMidi + 1;
const height = h / midiRange;
this.draw(
(ctx, events, t) => {
ctx.fillStyle = background;
ctx.clearRect(0, 0, w, h);
ctx.fillRect(0, 0, w, h);
events.forEach((event) => {
const isActive = event.whole.begin <= t && event.whole.end >= t;
ctx.fillStyle = event.context?.color || inactive;
ctx.strokeStyle = event.context?.color || active;
ctx.globalAlpha = event.context.velocity ?? 1;
const x = Math.round((event.whole.begin / timeframe) * w);
const width = Math.round(((event.whole.end - event.whole.begin) / timeframe) * w);
const y = Math.round(h - ((Number(event.value) - minMidi) / midiRange) * h);
const offset = (t / timeframe) * w;
const margin = 0;
const coords = [x - offset + margin + 1, y + 1, width - 2, height - 2];
isActive ? ctx.strokeRect(...coords) : ctx.fillRect(...coords);
});
},
timeframe,
2, // lookaheadCycles
);
return this;
};

View file

@ -0,0 +1,8 @@
// postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
// other plugins can go here, such as autoprefixer
},
};

View file

View file

@ -0,0 +1 @@
strudel.tidalcycles.org

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #2a3236;
}
.react-codemirror2,
.CodeMirror {
height: 100% !important;
background-color: transparent !important;
font-size: 15px;
z-index:20
}
.CodeMirror-line > span {
background-color: #2a323699;
}
.darken::before {
content: ' ';
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: block;
background: black;
opacity: 0.5;
}

View file

@ -0,0 +1,45 @@
// this file can be used to livecode from the comfort of your editor.
// just export a pattern from export default
// enable hot mode by pressing "toggle hot mode" on the top right of the repl
import { mini, h } from '../../mini/mini.mjs';
import { sequence, pure, reify, slowcat, fastcat, cat, stack, silence } from '../../packages/core/strudel.mjs';
import { gain, filter } from '../../tone/tone.mjs';
export default stack(
sequence(
mini(
'e5 [b4 c5] d5 [c5 b4]',
'a4 [a4 c5] e5 [d5 c5]',
'b4 [~ c5] d5 e5',
'c5 a4 a4 ~',
'[~ d5] [~ f5] a5 [g5 f5]',
'e5 [~ c5] e5 [d5 c5]',
'b4 [b4 c5] d5 e5',
'c5 a4 a4 ~'
)
.synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.1 },
})
.rev()
),
sequence(
mini(
'e2 e3 e2 e3 e2 e3 e2 e3',
'a2 a3 a2 a3 a2 a3 a2 a3',
'g#2 g#3 g#2 g#3 e2 e3 e2 e3',
'a2 a3 a2 a3 a2 a3 b1 c2',
'd2 d3 d2 d3 d2 d3 d2 d3',
'c2 c3 c2 c3 c2 c3 c2 c3',
'b1 b2 b1 b2 e2 e3 e2 e3',
'a1 a2 a1 a2 a1 a2 a1 a2'
)
.synth({
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.1 },
})
.chain(gain(0.7), filter(2000))
.rev()
)
).slow(16);

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="stylesheet" type="text/css" href="%PUBLIC_URL%/global.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Strudel REPL" />
<title>Strudel REPL</title>
</head>
<body>
<div id="root"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="%PUBLIC_URL%/dist/index.js"></script>
</body>
</html>

View file

@ -0,0 +1,41 @@
/** @type {import("snowpack").SnowpackUserConfig } */
export default {
workspaceRoot: '..',
mount: {
public: { url: '/', static: false },
src: { url: '/dist' },
},
plugins: [
'@snowpack/plugin-postcss',
'@snowpack/plugin-react-refresh',
'@snowpack/plugin-dotenv',
[
'@snowpack/plugin-typescript',
{
/* Yarn PnP workaround: see https://www.npmjs.com/package/@snowpack/plugin-typescript */
...(process.versions.pnp ? { tsc: 'yarn pnpify tsc' } : {}),
},
],
],
routes: [
/* Enable an SPA Fallback in development: */
// {"match": "routes", "src": ".*", "dest": "/index.html"},
],
exclude: ['**/node_modules/**/*', '**/tutorial/**/*'],
optimize: {
/* Example: Bundle your final build: */
// "bundle": true,
},
packageOptions: {
/* ... */
knownEntrypoints: ['fraction.js', 'codemirror', 'shift-ast', 'ramda'],
},
devOptions: {
tailwindConfig: './tailwind.config.js',
},
buildOptions: {
/* ... */
out: '../docs',
baseUrl: '/',
},
};

View file

@ -0,0 +1,66 @@
import * as Tone from 'tone';
import { State, TimeSpan } from '../core/strudel.mjs';
import { getPlayableNoteValue } from '../../packages/core/util.mjs';
import { evaluate } from '../eval/evaluate.mjs';
// this is a test to play back events with as less runtime code as possible..
// the code asks for the number of seconds to prequery
// after the querying is done, the events are scheduled
// after the scheduling is done, the transport is started
// nothing happens while tone.js runs except the schedule callback, which is a thin wrapper around triggerAttackRelease calls
// so all glitches that appear here should have nothing to do with strudel and or the repl
async function playStatic(code) {
Tone.getTransport().cancel();
Tone.getTransport().stop();
let start, took;
const seconds = Number(prompt('How many seconds to run?')) || 60;
start = performance.now();
console.log('evaluating..');
const { pattern: pat } = await evaluate(code);
took = performance.now() - start;
console.log('evaluate took', took, 'ms');
console.log('querying..');
start = performance.now();
const events = pat
?.query(new State(new TimeSpan(0, seconds)))
?.filter((event) => event.part.begin.equals(event.whole.begin))
?.map((event) => ({
time: event.whole.begin.valueOf(),
duration: event.whole.end.sub(event.whole.begin).valueOf(),
value: event.value,
context: event.context,
}));
took = performance.now() - start;
console.log('query took', took, 'ms');
console.log('scheduling..');
start = performance.now();
events.forEach((event) => {
Tone.getTransport().schedule((time) => {
try {
const { onTrigger, velocity } = event.context;
if (!onTrigger) {
if (defaultSynth) {
const note = getPlayableNoteValue(event);
defaultSynth.triggerAttackRelease(note, event.duration, time, velocity);
} else {
throw new Error('no defaultSynth passed to useRepl.');
}
} else {
onTrigger(time, event);
}
} catch (err) {
console.warn(err);
err.message = 'unplayable event: ' + err?.message;
console.error(err);
}
}, event.time);
});
took = performance.now() - start;
console.log('scheduling took', took, 'ms');
console.log('now starting!');
Tone.getTransport().start('+0.5');
}
export default playStatic;

View file

@ -0,0 +1,8 @@
module.exports = {
content: ['./public/**/*.html', './src/**/*.{js,jsx,ts,tsx,mdx}'],
// specify other options here
theme: {
extend: {},
},
plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
};

View file

@ -0,0 +1,50 @@
import * as Tone from 'tone';
export const hideHeader = () => {
document.getElementById('header').style = 'display:none';
};
function frame(callback) {
if (window.strudelAnimation) {
cancelAnimationFrame(window.strudelAnimation);
}
const animate = (animationTime) => {
const toneTime = Tone.getTransport().seconds;
callback(animationTime, toneTime);
window.strudelAnimation = requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}
export const backgroundImage = function (src, animateOptions = {}) {
const container = document.getElementById('code');
const bg = 'background-image:url(' + src + ');background-size:contain;';
container.style = bg;
const { className: initialClassName } = container;
const handleOption = (option, value) => {
({
style: () => (container.style = bg + ';' + value),
className: () => (container.className = value + ' ' + initialClassName),
}[option]());
};
const funcOptions = Object.entries(animateOptions).filter(([_, v]) => typeof v === 'function');
const stringOptions = Object.entries(animateOptions).filter(([_, v]) => typeof v === 'string');
stringOptions.forEach(([option, value]) => handleOption(option, value));
if (funcOptions.length === 0) {
return;
}
frame((_, t) =>
funcOptions.forEach(([option, value]) => {
handleOption(option, value(t));
}),
);
};
export const cleanup = () => {
const container = document.getElementById('code');
if (container) {
container.style = '';
container.className = 'grow relative'; // has to match App.tsx
}
};

View file

@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import type { ToneEventCallback } from 'tone';
import * as Tone from 'tone';
import { Hap, State, TimeSpan } from '../core/strudel.mjs';
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) {
// 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) => {
const toneEvent = {
time: event.whole.begin.valueOf(),
duration: event.whole.end.sub(event.whole.begin).valueOf(),
value: event.value,
context: event.context,
};
onEvent(time, toneEvent);
Tone.Draw.schedule(() => {
// do drawing or DOM manipulation here
onDraw?.(time, toneEvent);
}, 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

@ -0,0 +1,11 @@
import { useEffect } from 'react';
function usePostMessage(listener) {
useEffect(() => {
window.addEventListener('message', listener);
return () => window.removeEventListener('message', listener);
}, [listener]);
return (data) => window.postMessage(data, '*');
}
export default usePostMessage;

View file

@ -0,0 +1,174 @@
import { useCallback, useState, useMemo } from 'react';
import { evaluate } from '../../packages/eval/evaluate.mjs';
import { getPlayableNoteValue } from '../core/util.mjs';
import useCycle from './useCycle';
import usePostMessage from './usePostMessage';
let s4 = () => {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
};
function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw }: any) {
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 = code !== activeCode || error;
const generateHash = () => encodeURIComponent(btoa(code));
const activateCode = 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());
setError(undefined);
setActiveCode(_code);
setPending(false);
} catch (err: any) {
err.message = 'evaluation error: ' + err.message;
console.warn(err);
setError(err);
}
};
const pushLog = (message: string) => setLog((log) => log + `${log ? '\n\n' : ''}${message}`);
// logs events of cycle
const logCycle = (_events: any, cycle: any) => {
if (_events.length) {
// pushLog(`# cycle ${cycle}\n` + _events.map((e: any) => e.show()).join('\n'));
}
};
// below block allows disabling the highlighting by including "strudel disable-highlighting" in the code (as comment)
onDraw = useMemo(() => {
if (activeCode && !activeCode.includes('strudel disable-highlighting')) {
return onDraw;
}
}, [activeCode]);
// cycle hook to control scheduling
const cycle = useCycle({
onDraw,
onEvent: useCallback(
(time, event) => {
try {
onEvent?.(event);
const { onTrigger, velocity } = event.context;
if (!onTrigger) {
if (defaultSynth) {
const note = getPlayableNoteValue(event);
defaultSynth.triggerAttackRelease(note, event.duration, time, velocity);
} else {
throw new Error('no defaultSynth passed to useRepl.');
}
/* console.warn('no instrument chosen', event);
throw new Error(`no instrument chosen for ${JSON.stringify(event)}`); */
} else {
onTrigger(time, event);
}
} catch (err: any) {
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]
),
onQuery: useCallback(
(state) => {
try {
return pattern?.query(state) || [];
} catch (err: any) {
console.warn(err);
err.message = 'query error: ' + err.message;
setError(err);
return [];
}
},
[pattern]
),
onSchedule: useCallback((_events, cycle) => logCycle(_events, cycle), [pattern]),
ready: !!pattern && !!activeCode,
});
const broadcast = usePostMessage(({ data: { from, type } }) => {
if (type === 'start' && from !== id) {
// console.log('message', from, type);
cycle.setStarted(false);
setActiveCode(undefined);
}
});
// set active pattern on ctrl+enter
/* useLayoutEffect(() => {
// TODO: make sure this is only fired when editor has focus
const handleKeyPress = (e: any) => {
if (e.ctrlKey || e.altKey) {
switch (e.code) {
case 'Enter':
activateCode();
!cycle.started && cycle.start();
break;
case 'Period':
cycle.stop();
}
}
};
document.addEventListener('keypress', handleKeyPress);
return () => document.removeEventListener('keypress', handleKeyPress);
}, [pattern, code]); */
/* useWebMidi({
ready: useCallback(({ outputs }) => {
pushLog(`WebMidi ready! Just add .midi(${outputs.map((o) => `'${o.name}'`).join(' | ')}) to the pattern. `);
}, []),
connected: useCallback(({ outputs }) => {
pushLog(`Midi device connected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`);
}, []),
disconnected: useCallback(({ outputs }) => {
pushLog(`Midi device disconnected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`);
}, []),
}); */
const togglePlay = () => {
if (!cycle.started) {
activateCode();
} else {
cycle.stop();
}
};
return {
pending,
code,
setCode,
pattern,
error,
cycle,
setPattern,
dirty,
log,
togglePlay,
activateCode,
activeCode,
pushLog,
hash,
};
}
export default useRepl;

View file

@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
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) {
//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 };
}