Merge branch 'main' into haskell-parser

This commit is contained in:
Felix Roos 2024-05-16 14:08:51 +02:00
commit 9d5acc0f32
407 changed files with 29774 additions and 21574 deletions

View File

@ -27,7 +27,7 @@ jobs:
version: 8.11.0 version: 8.11.0
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 20
cache: "pnpm" cache: "pnpm"
- name: Install Dependencies - name: Install Dependencies
run: pnpm install run: pnpm install

View File

@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node-version: [18] node-version: [20]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

3
.gitignore vendored
View File

@ -127,3 +127,6 @@ fabric.properties
.idea/caches/build_file_checksums.ser .idea/caches/build_file_checksums.ser
# END JetBrains -> BEGIN JetBrains # END JetBrains -> BEGIN JetBrains
samples/*
!samples/README.md

View File

@ -9,4 +9,5 @@ packages/xen/tunejs.js
paper paper
pnpm-lock.yaml pnpm-lock.yaml
pnpm-workspace.yaml pnpm-workspace.yaml
**/dev-dist **/dev-dist
website/.astro

View File

@ -114,7 +114,7 @@ You can run the same check with `pnpm check`
## Package Workflow ## Package Workflow
The project is split into multiple [packages](https://github.com/tidalcycles/strudel/tree/main/packages) with independent versioning. The project is split into multiple [packages](https://github.com/tidalcycles/strudel/tree/main/packages) with independent versioning.
When you run `pnpm i` on the root folder, [pnpm workspaces](https://pnpm.io/workspaces) will install all dependencies of all subpackages. This will allow any js file to import `@strudel.cycles/<package-name>` to get the local version, When you run `pnpm i` on the root folder, [pnpm workspaces](https://pnpm.io/workspaces) will install all dependencies of all subpackages. This will allow any js file to import `@strudel/<package-name>` to get the local version,
allowing to develop multiple packages at the same time. allowing to develop multiple packages at the same time.
## Package Publishing ## Package Publishing

View File

@ -2,37 +2,28 @@
[![Strudel test status](https://github.com/tidalcycles/strudel/actions/workflows/test.yml/badge.svg)](https://github.com/tidalcycles/strudel/actions) [![Strudel test status](https://github.com/tidalcycles/strudel/actions/workflows/test.yml/badge.svg)](https://github.com/tidalcycles/strudel/actions)
An experiment in making a [Tidal](https://github.com/tidalcycles/tidal/) using web technologies. This software is slowly stabilising, but please continue to tread carefully. An experiment in making a [Tidal](https://github.com/tidalcycles/tidal/) using web technologies. This software is a bit more stable now, but please continue to tread carefully.
- Try it here: <https://strudel.cc> - Try it here: <https://strudel.cc>
- Docs: <https://strudel.cc/learn> - Docs: <https://strudel.cc/learn>
- Technical Blog Post: <https://loophole-letters.vercel.app/strudel> - Technical Blog Post: <https://loophole-letters.vercel.app/strudel>
- 1 Year of Strudel Blog Post: <https://loophole-letters.vercel.app/strudel1year> - 1 Year of Strudel Blog Post: <https://loophole-letters.vercel.app/strudel1year>
- 2 Years of Strudel Blog Post: <https://strudel.cc/blog/#year-2>
## Running Locally ## Running Locally
After cloning the project, you can run the REPL locally: After cloning the project, you can run the REPL locally:
```bash ```bash
pnpm run setup pnpm i
pnpm run repl pnpm dev
``` ```
## Using Strudel In Your Project ## Using Strudel In Your Project
There are multiple npm packages you can use to use strudel, or only parts of it, in your project: This project is organized into many [packages](./packages), which are also available on [npm](https://www.npmjs.com/search?q=%40strudel).
- [`core`](./packages/core/): tidal pattern engine Read more about how to use these in your own project [here](https://strudel.cc/technical-manual/project-start).
- [`mini`](./packages/mini): mini notation parser + core binding
- [`transpiler`](./packages/transpiler): user code transpiler
- [`webaudio`](./packages/webaudio): webaudio output
- [`osc`](./packages/osc): bindings to communicate via OSC
- [`midi`](./packages/midi): webmidi bindings
- [`serial`](./packages/serial): webserial bindings
- [`tonal`](./packages/tonal): tonal functions
- ... [and there are more](./packages/)
Click on the package names to find out more about each one.
## Contributing ## Contributing

22
bench/tunes.bench.mjs Normal file
View File

@ -0,0 +1,22 @@
import { queryCode, testCycles } from '../test/runtime.mjs';
import * as tunes from '../website/src/repl/tunes.mjs';
import { describe, bench } from 'vitest';
import { calculateTactus } from '../packages/core/index.mjs';
const tuneKeys = Object.keys(tunes);
describe('renders tunes', () => {
tuneKeys.forEach((key) => {
describe(key, () => {
calculateTactus(true);
bench(`+tactus`, async () => {
await queryCode(tunes[key], testCycles[key] || 1);
});
calculateTactus(false);
bench(`-tactus`, async () => {
await queryCode(tunes[key], testCycles[key] || 1);
});
calculateTactus(true);
});
});
});

View File

@ -7,7 +7,7 @@
/> />
<div id="output"></div> <div id="output"></div>
<script type="module"> <script type="module">
const strudel = await import('https://cdn.skypack.dev/@strudel.cycles/core@0.6.8'); const strudel = await import('https://cdn.skypack.dev/@strudel/core@0.6.8');
Object.assign(window, strudel); // assign all strudel functions to global scope to use with eval Object.assign(window, strudel); // assign all strudel functions to global scope to use with eval
const input = document.getElementById('text'); const input = document.getElementById('text');
const getEvents = () => { const getEvents = () => {

View File

@ -8,7 +8,7 @@
/> />
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>
<script type="module"> <script type="module">
const strudel = await import('https://cdn.skypack.dev/@strudel.cycles/core@0.6.8'); const strudel = await import('https://cdn.skypack.dev/@strudel/core@0.6.8');
// this adds all strudel functions to the global scope, to be used by eval // this adds all strudel functions to the global scope, to be used by eval
Object.assign(window, strudel); Object.assign(window, strudel);
// setup elements // setup elements

View File

@ -1,10 +1,9 @@
<!DOCTYPE html> <!doctype html>
<script src="https://unpkg.com/@strudel/web@1.0.3"></script>
<button id="play">play</button> <button id="play">play</button>
<button id="stop">stop</button> <button id="stop">stop</button>
<script type="module"> <script>
import { initStrudel } from 'https://cdn.skypack.dev/@strudel/web@0.8.2'; strudel.initStrudel();
initStrudel();
document.getElementById('play').addEventListener('click', () => evaluate('note("c a f e").jux(rev)')); document.getElementById('play').addEventListener('click', () => evaluate('note("c a f e").jux(rev)'));
document.getElementById('play').addEventListener('stop', () => hush()); document.getElementById('play').addEventListener('stop', () => hush());
</script> </script>

View File

@ -1,12 +1,12 @@
<!DOCTYPE html> <!doctype html>
<script src="https://unpkg.com/@strudel/web@1.0.3"></script>
<button id="a">A</button> <button id="a">A</button>
<button id="b">B</button> <button id="b">B</button>
<button id="c">C</button> <button id="c">C</button>
<button id="stop">stop</button> <button id="stop">stop</button>
<script type="module"> <script>
import { initStrudel } from 'https://cdn.skypack.dev/@strudel/web@0.8.2';
initStrudel({ initStrudel({
prebake: () => samples('github:tidalcycles/Dirt-Samples/master'), prebake: () => samples('github:tidalcycles/dirt-samples'),
}); });
const click = (id, action) => document.getElementById(id).addEventListener('click', action); const click = (id, action) => document.getElementById(id).addEventListener('click', action);
click('a', () => evaluate(`s('bd,jvbass(3,8)').jux(rev)`)); click('a', () => evaluate(`s('bd,jvbass(3,8)').jux(rev)`));

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -16,38 +16,45 @@
</div> </div>
<div id="output"></div> <div id="output"></div>
<script type="module"> <script type="module">
import { controls, repl, evalScope } from 'https://cdn.skypack.dev/@strudel.cycles/core@0.6.8'; // TODO: refactor to use newer version without controls import
import { mini } from 'https://cdn.skypack.dev/@strudel.cycles/mini@0.6.0'; import { controls, repl, evalScope } from 'https://cdn.skypack.dev/@strudel/core@0.11.0';
import { transpiler } from 'https://cdn.skypack.dev/@strudel.cycles/transpiler@0.6.0'; import { mini } from 'https://cdn.skypack.dev/@strudel/mini@0.11.0';
import { transpiler } from 'https://cdn.skypack.dev/@strudel/transpiler@0.11.0';
import { import {
getAudioContext, getAudioContext,
webaudioOutput, webaudioOutput,
initAudioOnFirstClick, initAudioOnFirstClick,
} from 'https://cdn.skypack.dev/@strudel.cycles/webaudio@0.6.0'; registerSynthSounds,
} from 'https://cdn.skypack.dev/@strudel/webaudio@0.11.0';
initAudioOnFirstClick();
const ctx = getAudioContext(); const ctx = getAudioContext();
const input = document.getElementById('text'); const input = document.getElementById('text');
input.innerHTML = getTune(); input.innerHTML = getTune();
evalScope( const loadModules = evalScope(
controls, controls,
import('https://cdn.skypack.dev/@strudel.cycles/core@0.6.8'), import('https://cdn.skypack.dev/@strudel/core@0.11.0'),
import('https://cdn.skypack.dev/@strudel.cycles/mini@0.6.0'), import('https://cdn.skypack.dev/@strudel/mini@0.11.0'),
import('https://cdn.skypack.dev/@strudel.cycles/tonal@0.6.0'), import('https://cdn.skypack.dev/@strudel/tonal@0.11.0'),
import('https://cdn.skypack.dev/@strudel.cycles/webaudio@0.6.0'), import('https://cdn.skypack.dev/@strudel/webaudio@0.11.0'),
); );
const initAudio = Promise.all([initAudioOnFirstClick(), registerSynthSounds()]);
const { evaluate } = repl({ const { evaluate } = repl({
defaultOutput: webaudioOutput, defaultOutput: webaudioOutput,
getTime: () => ctx.currentTime, getTime: () => ctx.currentTime,
transpiler, transpiler,
}); });
document.getElementById('start').addEventListener('click', () => evaluate(input.value)); document.getElementById('start').addEventListener('click', async () => {
await loadModules;
await initAudio;
evaluate(input.value);
});
function getTune() { function getTune() {
return `await samples('github:tidalcycles/Dirt-Samples/master') return `samples('github:tidalcycles/dirt-samples')
setcps(1);
stack( stack(
// amen // amen
n("0 1 2 3 4 5 6 7") n("0 1 2 3 4 5 6 7")

View File

@ -1,4 +1,4 @@
<script src="https://unpkg.com/@strudel.cycles/embed@latest"></script> <script src="https://unpkg.com/@strudel/embed@0.11.0"></script>
<!-- <script src="./embed.js"></script> --> <!-- <script src="./embed.js"></script> -->
<strudel-repl> <strudel-repl>
<!-- <!--

View File

@ -1,4 +1,4 @@
<script src="https://unpkg.com/@strudel/repl@0.9.4"></script> <script src="https://unpkg.com/@strudel/repl@1.0.2"></script>
<strudel-editor> <strudel-editor>
<!-- <!--
// @date 23-08-15 // @date 23-08-15

View File

@ -0,0 +1,8 @@
<script src="https://unpkg.com/@strudel/web@1.0.3"></script>
<button id="play">PLAY</button>
<script>
initStrudel({
prebake: () => samples('github:tidalcycles/dirt-samples'),
});
document.getElementById('play').addEventListener('click', () => s('bd sd').play());
</script>

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@ -1,11 +1,11 @@
import { StrudelMirror } from '@strudel/codemirror'; import { StrudelMirror } from '@strudel/codemirror';
import { funk42 } from './tunes'; import { funk42 } from './tunes';
import { drawPianoroll, evalScope, controls } from '@strudel.cycles/core'; import { drawPianoroll, evalScope } from '@strudel/core';
import './style.css'; import './style.css';
import { initAudioOnFirstClick } from '@strudel.cycles/webaudio'; import { initAudioOnFirstClick } from '@strudel/webaudio';
import { transpiler } from '@strudel.cycles/transpiler'; import { transpiler } from '@strudel/transpiler';
import { getAudioContext, webaudioOutput, registerSynthSounds } from '@strudel.cycles/webaudio'; import { getAudioContext, webaudioOutput, registerSynthSounds } from '@strudel/webaudio';
import { registerSoundfonts } from '@strudel.cycles/soundfonts'; import { registerSoundfonts } from '@strudel/soundfonts';
// init canvas // init canvas
const canvas = document.getElementById('roll'); const canvas = document.getElementById('roll');
@ -25,11 +25,10 @@ const editor = new StrudelMirror({
prebake: async () => { prebake: async () => {
initAudioOnFirstClick(); // needed to make the browser happy (don't await this here..) initAudioOnFirstClick(); // needed to make the browser happy (don't await this here..)
const loadModules = evalScope( const loadModules = evalScope(
controls, import('@strudel/core'),
import('@strudel.cycles/core'), import('@strudel/mini'),
import('@strudel.cycles/mini'), import('@strudel/tonal'),
import('@strudel.cycles/tonal'), import('@strudel/webaudio'),
import('@strudel.cycles/webaudio'),
); );
await Promise.all([loadModules, registerSynthSounds(), registerSoundfonts()]); await Promise.all([loadModules, registerSynthSounds(), registerSoundfonts()]);
}, },

View File

@ -9,15 +9,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"vite": "^4.3.2" "vite": "^5.0.10"
}, },
"dependencies": { "dependencies": {
"@strudel/codemirror": "workspace:*", "@strudel/codemirror": "workspace:*",
"@strudel.cycles/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel.cycles/mini": "workspace:*", "@strudel/mini": "workspace:*",
"@strudel.cycles/soundfonts": "workspace:*", "@strudel/soundfonts": "workspace:*",
"@strudel.cycles/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
"@strudel.cycles/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
"@strudel.cycles/webaudio": "workspace:*" "@strudel/webaudio": "workspace:*"
} }
} }

View File

@ -1,6 +1,6 @@
export const bumpStreet = `// froos - "22 bump street", licensed with CC BY-NC-SA 4.0 export const bumpStreet = `// froos - "22 bump street", licensed with CC BY-NC-SA 4.0
await samples('github:felixroos/samples/main') samples('github:felixroos/samples')
await samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/') samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
"<[0,<6 7 9>,13,<17 20 22 26>]!2>/2" "<[0,<6 7 9>,13,<17 20 22 26>]!2>/2"
// make it 22 edo // make it 22 edo
@ -33,8 +33,8 @@ await samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tid
export const trafficFlam = `// froos - "traffic flam", licensed with CC BY-NC-SA 4.0 export const trafficFlam = `// froos - "traffic flam", licensed with CC BY-NC-SA 4.0
await samples('github:felixroos/samples/main') samples('github:felixroos/samples')
await samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/') samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
addVoicings('hip', { addVoicings('hip', {
m11: ['2M 3m 4P 7m'], m11: ['2M 3m 4P 7m'],
@ -69,8 +69,8 @@ export const funk42 = `// froos - how to funk in 42 lines of code
// adapted from "how to funk in two minutes" by marc rebillet https://www.youtube.com/watch?v=3vBwRfQbXkg // adapted from "how to funk in two minutes" by marc rebillet https://www.youtube.com/watch?v=3vBwRfQbXkg
// thanks to peach for the transcription: https://www.youtube.com/watch?v=8eiPXvIgda4 // thanks to peach for the transcription: https://www.youtube.com/watch?v=8eiPXvIgda4
await samples('github:felixroos/samples/main') samples('github:felixroos/samples')
await samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/') samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
setcps(.5) setcps(.5)

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -15,7 +15,7 @@
<script type="module"> <script type="module">
import { initStrudel } from '@strudel/web'; import { initStrudel } from '@strudel/web';
initStrudel({ initStrudel({
prebake: () => samples('github:tidalcycles/Dirt-Samples/master'), prebake: () => samples('github:tidalcycles/dirt-samples'),
}); });
const click = (id, action) => document.getElementById(id).addEventListener('click', action); const click = (id, action) => document.getElementById(id).addEventListener('click', action);

View File

@ -10,7 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"vite": "^4.3.2" "vite": "^5.0.10"
}, },
"dependencies": { "dependencies": {
"@strudel/web": "workspace:*" "@strudel/web": "workspace:*"

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@ -1,20 +1,15 @@
import { controls, repl, evalScope } from '@strudel.cycles/core'; import { repl, evalScope } from '@strudel/core';
import { getAudioContext, webaudioOutput, initAudioOnFirstClick } from '@strudel.cycles/webaudio'; import { getAudioContext, webaudioOutput, initAudioOnFirstClick, registerSynthSounds } from '@strudel/webaudio';
import { transpiler } from '@strudel.cycles/transpiler'; import { transpiler } from '@strudel/transpiler';
import tune from './tune.mjs'; import tune from './tune.mjs';
const ctx = getAudioContext(); const ctx = getAudioContext();
const input = document.getElementById('text'); const input = document.getElementById('text');
input.innerHTML = tune; input.innerHTML = tune;
initAudioOnFirstClick(); initAudioOnFirstClick();
registerSynthSounds();
evalScope( evalScope(import('@strudel/core'), import('@strudel/mini'), import('@strudel/webaudio'), import('@strudel/tonal'));
controls,
import('@strudel.cycles/core'),
import('@strudel.cycles/mini'),
import('@strudel.cycles/webaudio'),
import('@strudel.cycles/tonal'),
);
const { evaluate } = repl({ const { evaluate } = repl({
defaultOutput: webaudioOutput, defaultOutput: webaudioOutput,

View File

@ -10,13 +10,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"vite": "^4.3.3" "vite": "^5.0.10"
}, },
"dependencies": { "dependencies": {
"@strudel.cycles/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel.cycles/mini": "workspace:*", "@strudel/mini": "workspace:*",
"@strudel.cycles/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
"@strudel.cycles/webaudio": "workspace:*", "@strudel/webaudio": "workspace:*",
"@strudel.cycles/tonal": "workspace:*" "@strudel/tonal": "workspace:*"
} }
} }

View File

@ -1,5 +1,5 @@
export default `await samples('github:tidalcycles/Dirt-Samples/master') export default `samples('github:tidalcycles/dirt-samples')
setcps(1)
stack( stack(
// amen // amen
n("0 1 2 3 4 5 6 7") n("0 1 2 3 4 5 6 7")

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<title>Superdough Example</title> <title>Superdough Example</title>
@ -12,7 +12,7 @@
const init = Promise.all([ const init = Promise.all([
initAudioOnFirstClick(), initAudioOnFirstClick(),
samples('github:tidalcycles/Dirt-Samples/master'), samples('github:tidalcycles/dirt-samples'),
registerSynthSounds(), registerSynthSounds(),
]); ]);

View File

@ -12,6 +12,6 @@
"superdough": "workspace:*" "superdough": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"vite": "^4.4.5" "vite": "^5.0.10"
} }
} }

View File

@ -1,15 +1,20 @@
// this barrel export is currently only used to find undocumented exports // this barrel export is currently only used to find undocumented exports
export * from './packages/codemirror/index.mjs';
export * from './packages/core/index.mjs'; export * from './packages/core/index.mjs';
export * from './packages/csound/index.mjs'; export * from './packages/csound/index.mjs';
export * from './packages/embed/index.mjs';
export * from './packages/desktopbridge/index.mjs'; export * from './packages/desktopbridge/index.mjs';
export * from './packages/draw/index.mjs';
export * from './packages/embed/index.mjs';
export * from './packages/hydra/index.mjs';
export * from './packages/midi/index.mjs'; export * from './packages/midi/index.mjs';
export * from './packages/mini/index.mjs'; export * from './packages/mini/index.mjs';
export * from './packages/osc/index.mjs'; export * from './packages/osc/index.mjs';
export * from './packages/react/index.mjs'; export * from './packages/repl/index.mjs';
export * from './packages/serial/index.mjs'; export * from './packages/serial/index.mjs';
export * from './packages/soundfonts/index.mjs'; export * from './packages/soundfonts/index.mjs';
export * from './packages/superdough/index.mjs';
export * from './packages/tonal/index.mjs'; export * from './packages/tonal/index.mjs';
export * from './packages/transpiler/index.mjs'; export * from './packages/transpiler/index.mjs';
export * from './packages/web/index.mjs';
export * from './packages/webaudio/index.mjs'; export * from './packages/webaudio/index.mjs';
export * from './packages/xen/index.mjs'; export * from './packages/xen/index.mjs';

View File

@ -1,8 +1,6 @@
{ {
"packages": [ "packages": ["packages/*"],
"packages/*"
],
"version": "independent", "version": "independent",
"npmClient": "pnpm", "npmClient": "pnpm",
"useWorkspaces": true "$schema": "node_modules/lerna/schemas/lerna-schema.json"
} }

View File

@ -1,5 +1,5 @@
{ {
"name": "@strudel.cycles/monorepo", "name": "@strudel/monorepo",
"version": "0.5.0", "version": "0.5.0",
"private": true, "private": true,
"description": "Port of tidalcycles to javascript", "description": "Port of tidalcycles to javascript",
@ -11,6 +11,7 @@
"test": "npm run pretest && vitest run --version", "test": "npm run pretest && vitest run --version",
"test-ui": "npm run pretest && vitest --ui", "test-ui": "npm run pretest && vitest --ui",
"test-coverage": "npm run pretest && vitest --coverage", "test-coverage": "npm run pretest && vitest --coverage",
"bench": "npm run pretest && vitest bench",
"snapshot": "npm run pretest && vitest run -u --silent", "snapshot": "npm run pretest && vitest run -u --silent",
"repl": "npm run prestart && cd website && npm run dev", "repl": "npm run prestart && cd website && npm run dev",
"start": "npm run prestart && cd website && npm run dev", "start": "npm run prestart && cd website && npm run dev",
@ -25,6 +26,7 @@
"format-check": "prettier --check .", "format-check": "prettier --check .",
"report-undocumented": "npm run jsdoc-json && node jsdoc/undocumented.mjs > undocumented.json", "report-undocumented": "npm run jsdoc-json && node jsdoc/undocumented.mjs > undocumented.json",
"check": "npm run format-check && npm run lint && npm run test", "check": "npm run format-check && npm run lint && npm run test",
"sampler": "cd samples && node ../packages/sampler/sample-server.mjs",
"iclc": "cd paper && pandoc --template=pandoc/iclc.html --citeproc --number-sections iclc2023.md -o iclc2023.html && pandoc --template=pandoc/iclc.latex --citeproc --number-sections iclc2023.md -o iclc2023.pdf" "iclc": "cd paper && pandoc --template=pandoc/iclc.html --citeproc --number-sections iclc2023.md -o iclc2023.html && pandoc --template=pandoc/iclc.latex --citeproc --number-sections iclc2023.md -o iclc2023.pdf"
}, },
"repository": { "repository": {
@ -45,28 +47,27 @@
}, },
"homepage": "https://strudel.cc", "homepage": "https://strudel.cc",
"dependencies": { "dependencies": {
"@strudel.cycles/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel.cycles/mini": "workspace:*", "@strudel/mini": "workspace:*",
"@strudel.cycles/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
"@strudel.cycles/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
"@strudel.cycles/webaudio": "workspace:*", "@strudel/webaudio": "workspace:*",
"@strudel.cycles/xen": "workspace:*", "@strudel/xen": "workspace:*"
"acorn": "^8.8.1",
"dependency-tree": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^1.4.0", "@tauri-apps/cli": "^1.5.9",
"@vitest/ui": "^0.28.0", "@vitest/ui": "^1.1.0",
"canvas": "^2.11.2", "acorn": "^8.11.3",
"eslint": "^8.39.0", "dependency-tree": "^10.0.9",
"eslint-plugin-import": "^2.27.5", "eslint": "^8.56.0",
"eslint-plugin-import": "^2.29.1",
"events": "^3.3.0", "events": "^3.3.0",
"jsdoc": "^4.0.2", "jsdoc": "^4.0.2",
"jsdoc-json": "^2.0.2", "jsdoc-json": "^2.0.2",
"jsdoc-to-markdown": "^8.0.0", "jsdoc-to-markdown": "^8.0.0",
"lerna": "^6.6.1", "lerna": "^8.0.1",
"prettier": "^2.8.8", "prettier": "^3.1.1",
"rollup-plugin-visualizer": "^5.8.1", "rollup-plugin-visualizer": "^5.12.0",
"vitest": "^0.33.0" "vitest": "^1.1.0"
} }
} }

View File

@ -1,5 +1,5 @@
# Packages # Packages
Each folder represents one of the @strudel.cycles/* packages [published to npm](https://www.npmjs.com/org/strudel.cycles). Each folder represents one of the @strudel/* packages [published to npm](https://www.npmjs.com/org/strudel).
To understand how those pieces connect, refer to the [Technical Manual](https://github.com/tidalcycles/strudel/wiki/Technical-Manual) or the individual READMEs. To understand how those pieces connect, refer to the [Technical Manual](https://github.com/tidalcycles/strudel/wiki/Technical-Manual) or the individual READMEs.

View File

@ -3,6 +3,12 @@ import jsdoc from '../../doc.json';
import { autocompletion } from '@codemirror/autocomplete'; import { autocompletion } from '@codemirror/autocomplete';
import { h } from './html'; import { h } from './html';
function plaintext(str) {
const div = document.createElement('div');
div.innerText = str;
return div.innerHTML;
}
const getDocLabel = (doc) => doc.name || doc.longname; const getDocLabel = (doc) => doc.name || doc.longname;
const getInnerText = (html) => { const getInnerText = (html) => {
var div = document.createElement('div'); var div = document.createElement('div');
@ -21,7 +27,7 @@ ${doc.description}
)} )}
</ul> </ul>
<div> <div>
${doc.examples?.map((example) => `<div><pre>${example}</pre></div>`)} ${doc.examples?.map((example) => `<div><pre>${plaintext(example)}</pre></div>`)}
</div> </div>
</div>`[0]; </div>`[0];
/* /*

View File

@ -2,21 +2,32 @@ import { closeBrackets } from '@codemirror/autocomplete';
// import { search, highlightSelectionMatches } from '@codemirror/search'; // import { search, highlightSelectionMatches } from '@codemirror/search';
import { history } from '@codemirror/commands'; import { history } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript'; import { javascript } from '@codemirror/lang-javascript';
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language';
import { Compartment, EditorState, Prec } from '@codemirror/state'; import { Compartment, EditorState, Prec } from '@codemirror/state';
import { EditorView, highlightActiveLineGutter, highlightActiveLine, keymap, lineNumbers } from '@codemirror/view'; import {
import { Pattern, Drawer, repl, cleanupDraw } from '@strudel.cycles/core'; EditorView,
highlightActiveLineGutter,
highlightActiveLine,
keymap,
lineNumbers,
drawSelection,
} from '@codemirror/view';
import { Pattern, repl } from '@strudel/core';
import { Drawer, cleanupDraw } from '@strudel/draw';
import { isAutoCompletionEnabled } from './autocomplete.mjs'; import { isAutoCompletionEnabled } from './autocomplete.mjs';
import { isTooltipEnabled } from './tooltip.mjs'; import { isTooltipEnabled } from './tooltip.mjs';
import { flash, isFlashEnabled } from './flash.mjs'; import { flash, isFlashEnabled } from './flash.mjs';
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
import { keybindings } from './keybindings.mjs'; import { keybindings } from './keybindings.mjs';
import { initTheme, activateTheme, theme } from './themes.mjs'; import { initTheme, activateTheme, theme } from './themes.mjs';
import { updateWidgets, sliderPlugin } from './slider.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { widgetPlugin, updateWidgets } from './widget.mjs';
import { persistentAtom } from '@nanostores/persistent'; import { persistentAtom } from '@nanostores/persistent';
const extensions = { const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []),
isBracketClosingEnabled: (on) => (on ? closeBrackets() : []),
isLineNumbersDisplayed: (on) => (on ? lineNumbers() : []), isLineNumbersDisplayed: (on) => (on ? lineNumbers() : []),
theme, theme,
isAutoCompletionEnabled, isAutoCompletionEnabled,
@ -30,6 +41,8 @@ const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [ke
export const defaultSettings = { export const defaultSettings = {
keybindings: 'codemirror', keybindings: 'codemirror',
isBracketMatchingEnabled: false,
isBracketClosingEnabled: true,
isLineNumbersDisplayed: true, isLineNumbersDisplayed: true,
isActiveLineHighlighted: false, isActiveLineHighlighted: false,
isAutoCompletionEnabled: false, isAutoCompletionEnabled: false,
@ -62,12 +75,13 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
...initialSettings, ...initialSettings,
javascript(), javascript(),
sliderPlugin, sliderPlugin,
widgetPlugin,
// indentOnInput(), // works without. already brought with javascript extension? // indentOnInput(), // works without. already brought with javascript extension?
// bracketMatching(), // does not do anything // bracketMatching(), // does not do anything
closeBrackets(),
syntaxHighlighting(defaultHighlightStyle), syntaxHighlighting(defaultHighlightStyle),
history(), history(),
EditorView.updateListener.of((v) => onChange(v)), EditorView.updateListener.of((v) => onChange(v)),
drawSelection({ cursorBlinkRate: 0 }),
Prec.highest( Prec.highest(
keymap.of([ keymap.of([
{ {
@ -115,6 +129,7 @@ export class StrudelMirror {
id, id,
initialCode = '', initialCode = '',
onDraw, onDraw,
drawContext,
drawTime = [0, 0], drawTime = [0, 0],
autodraw, autodraw,
prebake, prebake,
@ -127,23 +142,16 @@ export class StrudelMirror {
this.widgets = []; this.widgets = [];
this.painters = []; this.painters = [];
this.drawTime = drawTime; this.drawTime = drawTime;
this.onDraw = onDraw; this.drawContext = drawContext;
const self = this; this.onDraw = onDraw || this.draw;
this.id = id || s4(); this.id = id || s4();
this.drawer = new Drawer((haps, time) => { this.drawer = new Drawer((haps, time) => {
const currentFrame = haps.filter((hap) => time >= hap.whole.begin && time <= hap.endClipped); const currentFrame = haps.filter((hap) => hap.isActive(time));
this.highlight(currentFrame, time); this.highlight(currentFrame, time);
this.onDraw?.(haps, time, currentFrame, this.painters); this.onDraw(haps, time, this.painters);
}, drawTime); }, drawTime);
// this approach does not work with multiple repls on screen
// TODO: refactor onPaint usages + find fix, maybe remove painters here?
Pattern.prototype.onPaint = function (onPaint) {
self.painters.push(onPaint);
return this;
};
this.prebaked = prebake(); this.prebaked = prebake();
autodraw && this.drawFirstFrame(); autodraw && this.drawFirstFrame();
@ -169,6 +177,14 @@ export class StrudelMirror {
beforeEval: async () => { beforeEval: async () => {
cleanupDraw(); cleanupDraw();
this.painters = []; this.painters = [];
const self = this;
// this is similar to repl.mjs > injectPatternMethods
// maybe there is a solution without prototype hacking, but hey, it works
// we need to do this befor every eval to make sure it works with multiple StrudelMirror's side by side
Pattern.prototype.onPaint = function (onPaint) {
self.painters.push(onPaint);
return this;
};
await this.prebaked; await this.prebaked;
await replOptions?.beforeEval?.(); await replOptions?.beforeEval?.();
}, },
@ -176,7 +192,10 @@ export class StrudelMirror {
// remember for when highlighting is toggled on // remember for when highlighting is toggled on
this.miniLocations = options.meta?.miniLocations; this.miniLocations = options.meta?.miniLocations;
this.widgets = options.meta?.widgets; this.widgets = options.meta?.widgets;
updateWidgets(this.editor, this.widgets); const sliders = this.widgets.filter((w) => w.type === 'slider');
updateSliderWidgets(this.editor, sliders);
const widgets = this.widgets.filter((w) => w.type !== 'slider');
updateWidgets(this.editor, widgets);
updateMiniLocations(this.editor, this.miniLocations); updateMiniLocations(this.editor, this.miniLocations);
replOptions?.afterEval?.(options); replOptions?.afterEval?.(options);
this.adjustDrawTime(); this.adjustDrawTime();
@ -220,6 +239,9 @@ export class StrudelMirror {
// when no painters are set, [0,0] is enough (just highlighting) // when no painters are set, [0,0] is enough (just highlighting)
this.drawer.setDrawTime(this.painters.length ? this.drawTime : [0, 0]); this.drawer.setDrawTime(this.painters.length ? this.drawTime : [0, 0]);
} }
draw(haps, time) {
this.painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
}
async drawFirstFrame() { async drawFirstFrame() {
if (!this.onDraw) { if (!this.onDraw) {
return; return;
@ -230,7 +252,7 @@ export class StrudelMirror {
await this.repl.evaluate(this.code, false); await this.repl.evaluate(this.code, false);
this.drawer.invalidate(this.repl.scheduler, -0.001); this.drawer.invalidate(this.repl.scheduler, -0.001);
// draw at -0.001 to avoid haps at 0 to be visualized as active // draw at -0.001 to avoid haps at 0 to be visualized as active
this.onDraw?.(this.drawer.visibleHaps, -0.001, [], this.painters); this.onDraw?.(this.drawer.visibleHaps, -0.001, this.painters);
} catch (err) { } catch (err) {
console.warn('first frame could not be painted'); console.warn('first frame could not be painted');
} }
@ -282,9 +304,15 @@ export class StrudelMirror {
setLineWrappingEnabled(enabled) { setLineWrappingEnabled(enabled) {
this.reconfigureExtension('isLineWrappingEnabled', enabled); this.reconfigureExtension('isLineWrappingEnabled', enabled);
} }
setBracketMatchingEnabled(enabled) {
this.reconfigureExtension('isBracketMatchingEnabled', enabled);
}
setLineNumbersDisplayed(enabled) { setLineNumbersDisplayed(enabled) {
this.reconfigureExtension('isLineNumbersDisplayed', enabled); this.reconfigureExtension('isLineNumbersDisplayed', enabled);
} }
setBracketClosingEnabled(enabled) {
this.reconfigureExtension('isBracketClosingEnabled', enabled);
}
setTheme(theme) { setTheme(theme) {
this.reconfigureExtension('theme', theme); this.reconfigureExtension('theme', theme);
} }

View File

@ -57,6 +57,9 @@ const visibleMiniLocations = StateField.define({
// this is why we need to find a way to update the existing decorations, showing the ones that have an active range // this is why we need to find a way to update the existing decorations, showing the ones that have an active range
const haps = new Map(); const haps = new Map();
for (let hap of e.value.haps) { for (let hap of e.value.haps) {
if (!hap.context?.locations || !hap.whole) {
continue;
}
for (let { start, end } of hap.context.locations) { for (let { start, end } of hap.context.locations) {
let id = `${start}:${end}`; let id = `${start}:${end}`;
if (!haps.has(id) || haps.get(id).whole.begin.lt(hap.whole.begin)) { if (!haps.has(id) || haps.get(id).whole.begin.lt(hap.whole.begin)) {
@ -64,7 +67,6 @@ const visibleMiniLocations = StateField.define({
} }
} }
} }
visible = { atTime: e.value.atTime, haps }; visible = { atTime: e.value.atTime, haps };
} }
} }
@ -90,7 +92,7 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi
if (haps.has(id)) { if (haps.has(id)) {
const hap = haps.get(id); const hap = haps.get(id);
const color = hap.context.color ?? 'var(--foreground)'; const color = hap.value?.color ?? 'var(--foreground)';
// Get explicit channels for color values // Get explicit channels for color values
/* /*
const swatch = document.createElement('div'); const swatch = document.createElement('div');

View File

@ -3,3 +3,4 @@ export * from './highlight.mjs';
export * from './flash.mjs'; export * from './flash.mjs';
export * from './slider.mjs'; export * from './slider.mjs';
export * from './themes.mjs'; export * from './themes.mjs';
export * from './widget.mjs';

View File

@ -1,11 +1,10 @@
{ {
"name": "@strudel/codemirror", "name": "@strudel/codemirror",
"version": "0.9.0", "version": "1.0.1",
"description": "Codemirror Extensions for Strudel", "description": "Codemirror Extensions for Strudel",
"main": "index.mjs", "main": "index.mjs",
"publishConfig": { "publishConfig": {
"main": "dist/index.js", "main": "dist/index.mjs"
"module": "dist/index.mjs"
}, },
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
@ -33,24 +32,26 @@
}, },
"homepage": "https://github.com/tidalcycles/strudel#readme", "homepage": "https://github.com/tidalcycles/strudel#readme",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.6.0", "@codemirror/autocomplete": "^6.11.1",
"@codemirror/commands": "^6.2.4", "@codemirror/commands": "^6.3.3",
"@codemirror/lang-javascript": "^6.1.7", "@codemirror/lang-javascript": "^6.2.1",
"@codemirror/language": "^6.6.0", "@codemirror/language": "^6.10.0",
"@codemirror/search": "^6.0.0", "@codemirror/search": "^6.5.5",
"@codemirror/state": "^6.2.0", "@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.10.0", "@codemirror/view": "^6.23.0",
"@lezer/highlight": "^1.1.4", "@lezer/highlight": "^1.2.0",
"@nanostores/persistent": "^0.9.1",
"@replit/codemirror-emacs": "^6.0.1", "@replit/codemirror-emacs": "^6.0.1",
"@replit/codemirror-vim": "^6.0.14", "@replit/codemirror-vim": "^6.1.0",
"@replit/codemirror-vscode-keymap": "^6.0.2", "@replit/codemirror-vscode-keymap": "^6.0.2",
"@strudel.cycles/core": "workspace:*", "@strudel/core": "workspace:*",
"@uiw/codemirror-themes": "^4.19.16", "@strudel/draw": "workspace:*",
"@uiw/codemirror-themes-all": "^4.19.16", "@strudel/transpiler": "workspace:*",
"nanostores": "^0.8.1", "@uiw/codemirror-themes": "^4.21.21",
"@nanostores/persistent": "^0.8.0" "@uiw/codemirror-themes-all": "^4.21.21",
"nanostores": "^0.9.5"
}, },
"devDependencies": { "devDependencies": {
"vite": "^4.3.3" "vite": "^5.0.10"
} }
} }

View File

@ -1,6 +1,6 @@
import { ref, pure } from '@strudel.cycles/core'; import { ref, pure } from '@strudel/core';
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view'; import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
import { StateEffect, StateField } from '@codemirror/state'; import { StateEffect } from '@codemirror/state';
export let sliderValues = {}; export let sliderValues = {};
const getSliderID = (from) => `slider_${from}`; const getSliderID = (from) => `slider_${from}`;
@ -60,19 +60,21 @@ export class SliderWidget extends WidgetType {
} }
} }
export const setWidgets = StateEffect.define(); export const setSliderWidgets = StateEffect.define();
export const updateWidgets = (view, widgets) => { export const updateSliderWidgets = (view, widgets) => {
view.dispatch({ effects: setWidgets.of(widgets) }); view.dispatch({ effects: setSliderWidgets.of(widgets) });
}; };
function getWidgets(widgetConfigs, view) { function getSliders(widgetConfigs, view) {
return widgetConfigs.map(({ from, to, value, min, max, step }) => { return widgetConfigs
return Decoration.widget({ .filter((w) => w.type === 'slider')
widget: new SliderWidget(value, min, max, from, to, step, view), .map(({ from, to, value, min, max, step }) => {
side: 0, return Decoration.widget({
}).range(from /* , to */); widget: new SliderWidget(value, min, max, from, to, step, view),
}); side: 0,
}).range(from /* , to */);
});
} }
export const sliderPlugin = ViewPlugin.fromClass( export const sliderPlugin = ViewPlugin.fromClass(
@ -99,8 +101,8 @@ export const sliderPlugin = ViewPlugin.fromClass(
} }
} }
for (let e of tr.effects) { for (let e of tr.effects) {
if (e.is(setWidgets)) { if (e.is(setSliderWidgets)) {
this.decorations = Decoration.set(getWidgets(e.value, update.view)); this.decorations = Decoration.set(getSliders(e.value, update.view));
} }
} }
}); });

View File

@ -37,6 +37,7 @@ import whitescreen, { settings as whitescreenSettings } from './themes/whitescre
import teletext, { settings as teletextSettings } from './themes/teletext'; import teletext, { settings as teletextSettings } from './themes/teletext';
import algoboy, { settings as algoboySettings } from './themes/algoboy'; import algoboy, { settings as algoboySettings } from './themes/algoboy';
import terminal, { settings as terminalSettings } from './themes/terminal'; import terminal, { settings as terminalSettings } from './themes/terminal';
import { setTheme } from '@strudel/draw';
export const themes = { export const themes = {
strudelTheme, strudelTheme,
@ -513,6 +514,7 @@ export function activateTheme(name) {
.map(([key, value]) => `--${key}: ${value} !important;`) .map(([key, value]) => `--${key}: ${value} !important;`)
.join('\n')} .join('\n')}
}`; }`;
setTheme(themeSettings);
// tailwind dark mode // tailwind dark mode
if (themeSettings.light) { if (themeSettings.light) {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');

View File

@ -18,6 +18,7 @@ export default createTheme({
theme: 'light', theme: 'light',
settings, settings,
styles: [ styles: [
{ tag: t.labelName, color: '#0f380f' },
{ tag: t.keyword, color: '#0f380f' }, { tag: t.keyword, color: '#0f380f' },
{ tag: t.operator, color: '#0f380f' }, { tag: t.operator, color: '#0f380f' },
{ tag: t.special(t.variableName), color: '#0f380f' }, { tag: t.special(t.variableName), color: '#0f380f' },

View File

@ -15,6 +15,7 @@ export default createTheme({
theme: 'dark', theme: 'dark',
settings, settings,
styles: [ styles: [
{ tag: t.labelName, color: 'white' },
{ tag: t.keyword, color: 'white' }, { tag: t.keyword, color: 'white' },
{ tag: t.operator, color: 'white' }, { tag: t.operator, color: 'white' },
{ tag: t.special(t.variableName), color: 'white' }, { tag: t.special(t.variableName), color: 'white' },

View File

@ -18,6 +18,7 @@ export default createTheme({
theme: 'dark', theme: 'dark',
settings, settings,
styles: [ styles: [
{ tag: t.labelName, color: 'white' },
{ tag: t.keyword, color: 'white' }, { tag: t.keyword, color: 'white' },
{ tag: t.operator, color: 'white' }, { tag: t.operator, color: 'white' },
{ tag: t.special(t.variableName), color: 'white' }, { tag: t.special(t.variableName), color: 'white' },

View File

@ -15,6 +15,7 @@ export default createTheme({
gutterForeground: '#8a919966', gutterForeground: '#8a919966',
}, },
styles: [ styles: [
{ tag: t.labelName, color: '#89ddff' },
{ tag: t.keyword, color: '#c792ea' }, { tag: t.keyword, color: '#c792ea' },
{ tag: t.operator, color: '#89ddff' }, { tag: t.operator, color: '#89ddff' },
{ tag: t.special(t.variableName), color: '#eeffff' }, { tag: t.special(t.variableName), color: '#eeffff' },

View File

@ -27,6 +27,7 @@ export default createTheme({
theme: 'dark', theme: 'dark',
settings, settings,
styles: [ styles: [
{ tag: t.labelName, color: colorB },
{ tag: t.keyword, color: colorA }, { tag: t.keyword, color: colorA },
{ tag: t.operator, color: mini }, { tag: t.operator, color: mini },
{ tag: t.special(t.variableName), color: colorA }, { tag: t.special(t.variableName), color: colorA },

View File

@ -14,6 +14,7 @@ export default createTheme({
theme: 'dark', theme: 'dark',
settings, settings,
styles: [ styles: [
{ tag: t.labelName, color: '#41FF00' },
{ tag: t.keyword, color: '#41FF00' }, { tag: t.keyword, color: '#41FF00' },
{ tag: t.operator, color: '#41FF00' }, { tag: t.operator, color: '#41FF00' },
{ tag: t.special(t.variableName), color: '#41FF00' }, { tag: t.special(t.variableName), color: '#41FF00' },

View File

@ -16,6 +16,7 @@ export default createTheme({
theme: 'light', theme: 'light',
settings, settings,
styles: [ styles: [
{ tag: t.labelName, color: 'black' },
{ tag: t.keyword, color: 'black' }, { tag: t.keyword, color: 'black' },
{ tag: t.operator, color: 'black' }, { tag: t.operator, color: 'black' },
{ tag: t.special(t.variableName), color: 'black' }, { tag: t.special(t.variableName), color: 'black' },

View File

@ -8,8 +8,8 @@ export default defineConfig({
build: { build: {
lib: { lib: {
entry: resolve(__dirname, 'index.mjs'), entry: resolve(__dirname, 'index.mjs'),
formats: ['es', 'cjs'], formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.js' }[ext]), fileName: (ext) => ({ es: 'index.mjs' })[ext],
}, },
rollupOptions: { rollupOptions: {
external: [...Object.keys(dependencies)], external: [...Object.keys(dependencies)],

View File

@ -0,0 +1,135 @@
import { StateEffect, StateField } from '@codemirror/state';
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
import { Pattern } from '@strudel/core';
export const addWidget = StateEffect.define({
map: ({ from, to }, change) => {
return { from: change.mapPos(from), to: change.mapPos(to) };
},
});
export const updateWidgets = (view, widgets) => {
view.dispatch({ effects: addWidget.of(widgets) });
};
function getWidgets(widgetConfigs) {
return (
widgetConfigs
// codemirror throws an error if we don't sort
.sort((a, b) => a.to - b.to)
.map((widgetConfig) => {
return Decoration.widget({
widget: new BlockWidget(widgetConfig),
side: 0,
block: true,
}).range(widgetConfig.to);
})
);
}
const widgetField = StateField.define(
/* <DecorationSet> */ {
create() {
return Decoration.none;
},
update(widgets, tr) {
widgets = widgets.map(tr.changes);
for (let e of tr.effects) {
if (e.is(addWidget)) {
try {
widgets = widgets.update({
filter: () => false,
add: getWidgets(e.value),
});
} catch (error) {
console.log('err', error);
}
}
}
return widgets;
},
provide: (f) => EditorView.decorations.from(f),
},
);
const widgetElements = {};
export function setWidget(id, el) {
widgetElements[id] = el;
el.id = id;
}
export class BlockWidget extends WidgetType {
constructor(widgetConfig) {
super();
this.widgetConfig = widgetConfig;
}
eq() {
return true;
}
toDOM() {
const id = getWidgetID(this.widgetConfig);
const el = widgetElements[id];
return el;
}
ignoreEvent(e) {
return true;
}
}
export const widgetPlugin = [widgetField];
// widget implementer API to create a new widget type
export function registerWidget(type, fn) {
registerWidgetType(type);
if (fn) {
Pattern.prototype[type] = function (id, options = { fold: 1 }) {
// fn is expected to create a dom element and call setWidget(id, el);
// fn should also return the pattern
return fn(id, options, this);
};
}
}
// wire up @strudel/draw functions
function getCanvasWidget(id, options = {}) {
const { width = 500, height = 60, pixelRatio = window.devicePixelRatio } = options;
let canvas = document.getElementById(id) || document.createElement('canvas');
canvas.width = width * pixelRatio;
canvas.height = height * pixelRatio;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
setWidget(id, canvas);
return canvas;
}
registerWidget('_pianoroll', (id, options = {}, pat) => {
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.tag(id).pianoroll({ fold: 1, ...options, ctx, id });
});
registerWidget('_punchcard', (id, options = {}, pat) => {
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.tag(id).punchcard({ fold: 1, ...options, ctx, id });
});
registerWidget('_spiral', (id, options = {}, pat) => {
let _size = options.size || 275;
options = { width: _size, height: _size, ...options, size: _size / 5 };
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.tag(id).spiral({ ...options, ctx, id });
});
registerWidget('_scope', (id, options = {}, pat) => {
options = { width: 500, height: 60, pos: 0.5, scale: 1, ...options };
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.tag(id).scope({ ...options, ctx, id });
});
registerWidget('_pitchwheel', (id, options = {}, pat) => {
let _size = options.size || 200;
options = { width: _size, height: _size, ...options, size: _size / 5 };
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.pitchwheel({ ...options, ctx, id });
});

View File

@ -1,17 +1,17 @@
# @strudel.cycles/core # @strudel/core
This package contains the bare essence of strudel. This package contains the bare essence of strudel.
## Install ## Install
```sh ```sh
npm i @strudel.cycles/core --save npm i @strudel/core --save
``` ```
## Example ## Example
```js ```js
import { sequence } from '@strudel.cycles/core'; import { sequence } from '@strudel/core';
const pattern = sequence('a', ['b', 'c']); const pattern = sequence('a', ['b', 'c']);
@ -33,7 +33,7 @@ b: 3/2 - 7/4
c: 7/4 - 2 c: 7/4 - 2
``` ```
- [play with @strudel.cycles/core on codesandbox](https://codesandbox.io/s/strudel-core-test-forked-9ywhv7?file=/src/index.js). - [play with @strudel/core on codesandbox](https://codesandbox.io/s/strudel-core-test-forked-9ywhv7?file=/src/index.js).
- [open color pattern example](https://raw.githack.com/tidalcycles/strudel/main/packages/core/examples/canvas.html) - [open color pattern example](https://raw.githack.com/tidalcycles/strudel/main/packages/core/examples/canvas.html)
- [open minimal repl example](https://raw.githack.com/tidalcycles/strudel/main/packages/core/examples/vanilla.html) - [open minimal repl example](https://raw.githack.com/tidalcycles/strudel/main/packages/core/examples/vanilla.html)
- [open minimal vite example](./examples/vite-vanilla-repl/) - [open minimal vite example](./examples/vite-vanilla-repl/)

View File

@ -0,0 +1,46 @@
import { describe, bench } from 'vitest';
import { calculateTactus, sequence, stack } from '../index.mjs';
const pat64 = sequence(...Array(64).keys());
describe('tactus', () => {
calculateTactus(true);
bench(
'+tactus',
() => {
pat64.iter(64).fast(64).firstCycle();
},
{ time: 1000 },
);
calculateTactus(false);
bench(
'-tactus',
() => {
pat64.iter(64).fast(64).firstCycle();
},
{ time: 1000 },
);
});
describe('stack', () => {
calculateTactus(true);
bench(
'+tactus',
() => {
stack(pat64, pat64, pat64, pat64, pat64, pat64, pat64, pat64).fast(64).firstCycle();
},
{ time: 1000 },
);
calculateTactus(false);
bench(
'-tactus',
() => {
stack(pat64, pat64, pat64, pat64, pat64, pat64, pat64, pat64).fast(64).firstCycle();
},
{ time: 1000 },
);
});
calculateTactus(true);

View File

@ -0,0 +1,168 @@
// eslint-disable-next-line no-undef
// TODO: swap below line with above one when firefox supports esm imports in service workers
// see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker?retiredLocale=de#browser_compatibility
// import createClock from './zyklus.mjs';
function getTime() {
const precision = 10 ** 4;
const seconds = performance.now() / 1000;
return Math.round(seconds * precision) / precision;
}
let num_cycles_at_cps_change = 0;
let num_ticks_since_cps_change = 0;
let num_seconds_at_cps_change = 0;
let cps = 0.5;
// {id: {started: boolean}}
const clients = new Map();
const duration = 0.1;
const channel = new BroadcastChannel('strudeltick');
const sendMessage = (type, payload) => {
channel.postMessage({ type, payload });
};
const sendTick = (phase, duration, tick, time) => {
const num_seconds_since_cps_change = num_ticks_since_cps_change * duration;
const tickdeadline = phase - time;
const lastTick = time + tickdeadline;
const num_cycles_since_cps_change = num_seconds_since_cps_change * cps;
const begin = num_cycles_at_cps_change + num_cycles_since_cps_change;
const secondsSinceLastTick = time - lastTick - duration;
const eventLength = duration * cps;
const end = begin + eventLength;
const cycle = begin + secondsSinceLastTick * cps;
sendMessage('tick', {
begin,
end,
cps,
tickdeadline,
num_cycles_at_cps_change,
num_seconds_at_cps_change,
num_seconds_since_cps_change,
cycle,
});
num_ticks_since_cps_change++;
};
//create clock method from zyklus
const clock = createClock(getTime, sendTick, duration);
let started = false;
const startClock = (id) => {
clients.set(id, { started: true });
if (started) {
return;
}
clock.start();
started = true;
};
const stopClock = async (id) => {
clients.set(id, { started: false });
const otherClientStarted = Array.from(clients.values()).some((c) => c.started);
//dont stop the clock if other instances are running...
if (!started || otherClientStarted) {
return;
}
clock.stop();
setCycle(0);
started = false;
};
const setCycle = (cycle) => {
num_ticks_since_cps_change = 0;
num_cycles_at_cps_change = cycle;
};
const processMessage = (message) => {
const { type, payload } = message;
switch (type) {
case 'cpschange': {
if (payload.cps !== cps) {
const num_seconds_since_cps_change = num_ticks_since_cps_change * duration;
num_cycles_at_cps_change = num_cycles_at_cps_change + num_seconds_since_cps_change * cps;
num_seconds_at_cps_change = num_seconds_at_cps_change + num_seconds_since_cps_change;
cps = payload.cps;
num_ticks_since_cps_change = 0;
}
break;
}
case 'setcycle': {
setCycle(payload.cycle);
break;
}
case 'toggle': {
if (payload.started) {
startClock(message.id);
} else {
stopClock(message.id);
}
break;
}
}
};
self.onconnect = function (e) {
// the incoming port
const port = e.ports[0];
port.addEventListener('message', function (e) {
processMessage(e.data);
});
port.start(); // Required when using addEventListener. Otherwise called implicitly by onmessage setter.
};
// used to consistently schedule events, for use in a service worker - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/clockworker.mjs>
function createClock(
getTime,
callback, // called slightly before each cycle
duration = 0.05, // duration of each cycle
interval = 0.1, // interval between callbacks
overlap = 0.1, // overlap between callbacks
) {
let tick = 0; // counts callbacks
let phase = 0; // next callback time
let precision = 10 ** 4; // used to round phase
let minLatency = 0.01;
const setDuration = (setter) => (duration = setter(duration));
overlap = overlap || interval / 2;
const onTick = () => {
const t = getTime();
const lookahead = t + interval + overlap; // the time window for this tick
if (phase === 0) {
phase = t + minLatency;
}
// callback as long as we're inside the lookahead
while (phase < lookahead) {
phase = Math.round(phase * precision) / precision;
phase >= t && callback(phase, duration, tick, t);
phase < t && console.log('TOO LATE', phase); // what if latency is added from outside?
phase += duration; // increment phase by duration
tick++;
}
};
let intervalID;
const start = () => {
clear(); // just in case start was called more than once
onTick();
intervalID = setInterval(onTick, interval * 1000);
};
const clear = () => intervalID !== undefined && clearInterval(intervalID);
const pause = () => clear();
const stop = () => {
tick = 0;
phase = 0;
clear();
};
const getPhase = () => phase;
// setCallback
return { setDuration, start, stop, pause, duration, interval, getPhase, minLatency };
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
/* /*
cyclist.mjs - <short description TODO> cyclist.mjs - event scheduler for a single strudel instance. for multi-instance scheduler, see - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/neocyclist.mjs>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/cyclist.mjs> Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/cyclist.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/>. 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/>.
*/ */
@ -8,49 +8,55 @@ import createClock from './zyklus.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
export class Cyclist { export class Cyclist {
constructor({ interval, onTrigger, onToggle, onError, getTime, latency = 0.1 }) { constructor({ interval, onTrigger, onToggle, onError, getTime, latency = 0.1, setInterval, clearInterval }) {
this.started = false; this.started = false;
this.cps = 1; this.cps = 0.5;
this.num_ticks_since_cps_change = 0; this.num_ticks_since_cps_change = 0;
this.lastTick = 0; // absolute time when last tick (clock callback) happened this.lastTick = 0; // absolute time when last tick (clock callback) happened
this.lastBegin = 0; // query begin of last tick this.lastBegin = 0; // query begin of last tick
this.lastEnd = 0; // query end of last tick this.lastEnd = 0; // query end of last tick
this.getTime = getTime; // get absolute time this.getTime = getTime; // get absolute time
this.num_cycles_since_last_cps_change = 0; this.num_cycles_at_cps_change = 0;
this.seconds_at_cps_change; // clock phase when cps was changed
this.onToggle = onToggle; this.onToggle = onToggle;
this.latency = latency; // fixed trigger time offset this.latency = latency; // fixed trigger time offset
this.clock = createClock( this.clock = createClock(
getTime, getTime,
// called slightly before each cycle // called slightly before each cycle
(phase, duration, tick) => { (phase, duration, _, t) => {
if (tick === 0) {
this.origin = phase;
}
if (this.num_ticks_since_cps_change === 0) { if (this.num_ticks_since_cps_change === 0) {
this.num_cycles_since_last_cps_change = this.lastEnd; this.num_cycles_at_cps_change = this.lastEnd;
this.seconds_at_cps_change = phase;
} }
this.num_ticks_since_cps_change++; this.num_ticks_since_cps_change++;
const seconds_since_cps_change = this.num_ticks_since_cps_change * duration;
const num_cycles_since_cps_change = seconds_since_cps_change * this.cps;
try { try {
const time = getTime();
const begin = this.lastEnd; const begin = this.lastEnd;
this.lastBegin = begin; this.lastBegin = begin;
const end = this.num_cycles_at_cps_change + num_cycles_since_cps_change;
//convert ticks to cycles, so you can query the pattern for events
const eventLength = duration * this.cps;
const end = this.num_cycles_since_last_cps_change + this.num_ticks_since_cps_change * eventLength;
this.lastEnd = end; this.lastEnd = end;
this.lastTick = phase;
if (phase < t) {
// avoid querying haps that are in the past anyway
console.log(`skip query: too late`);
return;
}
// query the pattern for events // query the pattern for events
const haps = this.pattern.queryArc(begin, end); const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
const tickdeadline = phase - time; // time left until the phase is a whole number
this.lastTick = time + tickdeadline;
haps.forEach((hap) => { haps.forEach((hap) => {
if (hap.part.begin.equals(hap.whole.begin)) { if (hap.hasOnset()) {
const deadline = (hap.whole.begin - begin) / this.cps + tickdeadline + latency; const targetTime =
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
const duration = hap.duration / this.cps; const duration = hap.duration / this.cps;
onTrigger?.(hap, deadline, duration, this.cps); // the following line is dumb and only here for backwards compatibility
// see https://github.com/tidalcycles/strudel/pull/1004
const deadline = targetTime - phase;
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
} }
}); });
} catch (e) { } catch (e) {
@ -59,9 +65,16 @@ export class Cyclist {
} }
}, },
interval, // duration of each cycle interval, // duration of each cycle
0.1,
0.1,
setInterval,
clearInterval,
); );
} }
now() { now() {
if (!this.started) {
return 0;
}
const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration; const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration;
return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency; return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency;
} }
@ -71,7 +84,7 @@ export class Cyclist {
} }
start() { start() {
this.num_ticks_since_cps_change = 0; this.num_ticks_since_cps_change = 0;
this.num_cycles_since_last_cps_change = 0; this.num_cycles_at_cps_change = 0;
if (!this.pattern) { if (!this.pattern) {
throw new Error('Scheduler: no pattern set! call .setPattern first.'); throw new Error('Scheduler: no pattern set! call .setPattern first.');
} }
@ -96,7 +109,7 @@ export class Cyclist {
this.start(); this.start();
} }
} }
setCps(cps = 1) { setCps(cps = 0.5) {
if (this.cps === cps) { if (this.cps === cps) {
return; return;
} }

View File

@ -41,11 +41,17 @@ const _bjork = function (n, x) {
}; };
export const bjork = function (ons, steps) { export const bjork = function (ons, steps) {
const inverted = ons < 0;
ons = Math.abs(ons);
const offs = steps - ons; const offs = steps - ons;
const x = Array(ons).fill([1]); const x = Array(ons).fill([1]);
const y = Array(offs).fill([0]); const y = Array(offs).fill([0]);
const result = _bjork([ons, offs], [x, y]); const result = _bjork([ons, offs], [x, y]);
return flatten(result[1][0]).concat(flatten(result[1][1])); const p = flatten(result[1][0]).concat(flatten(result[1][1]));
if (inverted) {
return p.map((x) => (x === 0 ? 1 : 0));
}
return p;
}; };
/** /**
@ -148,7 +154,7 @@ export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], fun
* @param {number} pulses the number of onsets / beats * @param {number} pulses the number of onsets / beats
* @param {number} steps the number of steps to fill * @param {number} steps the number of steps to fill
* @example * @example
* n("g2").decay(.1).sustain(.3).euclidLegato(3,8) * note("c3").euclidLegato(3,8)
*/ */
const _euclidLegato = function (pulses, steps, rotation, pat) { const _euclidLegato = function (pulses, steps, rotation, pat) {

View File

@ -22,6 +22,7 @@ export const evalScope = async (...args) => {
globalThis[name] = value; globalThis[name] = value;
}); });
}); });
return modules;
}; };
function safeEval(str, options = {}) { function safeEval(str, options = {}) {

View File

@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import Fraction from 'fraction.js'; import Fraction from 'fraction.js';
import { TimeSpan } from './timespan.mjs'; import { TimeSpan } from './timespan.mjs';
import { removeUndefineds } from './util.mjs';
// Returns the start of the cycle. // Returns the start of the cycle.
Fraction.prototype.sam = function () { Fraction.prototype.sam = function () {
@ -47,14 +48,39 @@ Fraction.prototype.eq = function (other) {
return this.compare(other) == 0; return this.compare(other) == 0;
}; };
Fraction.prototype.ne = function (other) {
return this.compare(other) != 0;
};
Fraction.prototype.max = function (other) { Fraction.prototype.max = function (other) {
return this.gt(other) ? this : other; return this.gt(other) ? this : other;
}; };
Fraction.prototype.maximum = function (...others) {
others = others.map((x) => new Fraction(x));
return others.reduce((max, other) => other.max(max), this);
};
Fraction.prototype.min = function (other) { Fraction.prototype.min = function (other) {
return this.lt(other) ? this : other; return this.lt(other) ? this : other;
}; };
Fraction.prototype.mulmaybe = function (other) {
return other !== undefined ? this.mul(other) : undefined;
};
Fraction.prototype.divmaybe = function (other) {
return other !== undefined ? this.div(other) : undefined;
};
Fraction.prototype.addmaybe = function (other) {
return other !== undefined ? this.add(other) : undefined;
};
Fraction.prototype.submaybe = function (other) {
return other !== undefined ? this.sub(other) : undefined;
};
Fraction.prototype.show = function (/* excludeWhole = false */) { Fraction.prototype.show = function (/* excludeWhole = false */) {
// return this.toFraction(excludeWhole); // return this.toFraction(excludeWhole);
return this.s * this.n + '/' + this.d; return this.s * this.n + '/' + this.d;
@ -80,9 +106,26 @@ const fraction = (n) => {
}; };
export const gcd = (...fractions) => { export const gcd = (...fractions) => {
fractions = removeUndefineds(fractions);
if (fractions.length === 0) {
return undefined;
}
return fractions.reduce((gcd, fraction) => gcd.gcd(fraction), fraction(1)); return fractions.reduce((gcd, fraction) => gcd.gcd(fraction), fraction(1));
}; };
export const lcm = (...fractions) => {
fractions = removeUndefineds(fractions);
if (fractions.length === 0) {
return undefined;
}
return fractions.reduce(
(lcm, fraction) => (lcm === undefined || fraction === undefined ? undefined : lcm.lcm(fraction)),
fraction(1),
);
};
fraction._original = Fraction; fraction._original = Fraction;
export default fraction; export default fraction;

View File

@ -3,6 +3,7 @@ hap.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/hap.mjs> Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/hap.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/>. 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 Fraction from './fraction.mjs';
export class Hap { export class Hap {
/* /*
@ -32,13 +33,43 @@ export class Hap {
} }
get duration() { get duration() {
return this.whole.end.sub(this.whole.begin).mul(typeof this.value?.clip === 'number' ? this.value?.clip : 1); let duration;
if (typeof this.value?.duration === 'number') {
duration = Fraction(this.value.duration);
} else {
duration = this.whole.end.sub(this.whole.begin);
}
if (typeof this.value?.clip === 'number') {
return duration.mul(this.value.clip);
}
return duration;
} }
get endClipped() { get endClipped() {
return this.whole.begin.add(this.duration); return this.whole.begin.add(this.duration);
} }
isActive(currentTime) {
return this.whole.begin <= currentTime && this.endClipped >= currentTime;
}
isInPast(currentTime) {
return currentTime > this.endClipped;
}
isInNearPast(margin, currentTime) {
return currentTime - margin <= this.endClipped;
}
isInFuture(currentTime) {
return currentTime < this.whole.begin;
}
isInNearFuture(margin, currentTime) {
return currentTime < this.whole.begin && currentTime > this.whole.begin - margin;
}
isWithinTime(min, max) {
return this.whole.begin <= max && this.endClipped >= min;
}
wholeOrPart() { wholeOrPart() {
return this.whole ? this.whole : this.part; return this.whole ? this.whole : this.part;
} }
@ -60,6 +91,10 @@ export class Hap {
return this.whole != undefined && this.whole.begin.equals(this.part.begin); return this.whole != undefined && this.whole.begin.equals(this.part.begin);
} }
hasTag(tag) {
return this.context.tags?.includes(tag);
}
resolveState(state) { resolveState(state) {
if (this.stateful && this.hasOnset()) { if (this.stateful && this.hasOnset()) {
console.log('stateful'); console.log('stateful');

View File

@ -4,11 +4,13 @@ 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/>. 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 controls from './controls.mjs'; import * as controls from './controls.mjs'; // legacy
export * from './euclid.mjs'; export * from './euclid.mjs';
import Fraction from './fraction.mjs'; import Fraction from './fraction.mjs';
import createClock from './zyklus.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
export { Fraction, controls }; export { Fraction, controls, createClock };
export * from './controls.mjs';
export * from './hap.mjs'; export * from './hap.mjs';
export * from './pattern.mjs'; export * from './pattern.mjs';
export * from './signal.mjs'; export * from './signal.mjs';
@ -21,21 +23,17 @@ export * from './repl.mjs';
export * from './cyclist.mjs'; export * from './cyclist.mjs';
export * from './logger.mjs'; export * from './logger.mjs';
export * from './time.mjs'; export * from './time.mjs';
export * from './draw.mjs';
export * from './animate.mjs';
export * from './pianoroll.mjs';
export * from './spiral.mjs';
export * from './ui.mjs'; export * from './ui.mjs';
export { default as drawLine } from './drawLine.mjs'; export { default as drawLine } from './drawLine.mjs';
// below won't work with runtime.mjs (json import fails) // below won't work with runtime.mjs (json import fails)
/* import * as p from './package.json'; /* import * as p from './package.json';
export const version = p.version; */ export const version = p.version; */
logger('🌀 @strudel.cycles/core loaded 🌀'); logger('🌀 @strudel/core loaded 🌀');
if (globalThis._strudelLoaded) { if (globalThis._strudelLoaded) {
console.warn( console.warn(
`@strudel.cycles/core was loaded more than once... `@strudel/core was loaded more than once...
This might happen when you have multiple versions of strudel installed. This might happen when you have multiple versions of strudel installed.
Please check with "npm ls @strudel.cycles/core".`, Please check with "npm ls @strudel/core".`,
); );
} }
globalThis._strudelLoaded = true; globalThis._strudelLoaded = true;

View File

@ -1,6 +1,16 @@
export const logKey = 'strudel.log'; export const logKey = 'strudel.log';
let debounce = 1000,
lastMessage,
lastTime;
export function logger(message, type, data = {}) { export function logger(message, type, data = {}) {
let t = performance.now();
if (lastMessage === message && t - lastTime < debounce) {
return;
}
lastMessage = message;
lastTime = t;
console.log(`%c${message}`, 'background-color: black;color:white;border-radius:15px'); console.log(`%c${message}`, 'background-color: black;color:white;border-radius:15px');
if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') { if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') {
document.dispatchEvent( document.dispatchEvent(

View File

@ -0,0 +1,147 @@
/*
neocyclist.mjs - event scheduler like cyclist, except recieves clock pulses from clockworker in order to sync across multiple instances.
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/neocyclist.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 { logger } from './logger.mjs';
export class NeoCyclist {
constructor({ onTrigger, onToggle, getTime }) {
this.started = false;
this.cps = 0.5;
this.lastTick = 0; // absolute time when last tick (clock callback) happened
this.getTime = getTime; // get absolute time
this.time_at_last_tick_message = 0;
this.num_cycles_at_cps_change = 0;
this.onToggle = onToggle;
this.latency = 0.1; // fixed trigger time offset
this.cycle = 0;
this.id = Math.round(Date.now() * Math.random());
this.worker_time_dif;
this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url));
this.worker.port.start();
this.channel = new BroadcastChannel('strudeltick');
let weight = 0; // the amount of weight that is applied to the current average when averaging a new time dif
const maxWeight = 20;
const precision = 10 ** 3; //round off time diff to prevent accumulating outliers
// the clock of the worker and the audio context clock can drift apart over time
// aditionally, the message time of the worker pinging the callback to process haps can be inconsistent.
// we need to keep a rolling weighted average of the time difference between the worker clock and audio context clock
// in order to schedule events consistently.
const setTimeReference = (num_seconds_at_cps_change, num_seconds_since_cps_change, tickdeadline) => {
const time_dif = getTime() - (num_seconds_at_cps_change + num_seconds_since_cps_change) + tickdeadline;
if (this.worker_time_dif == null) {
this.worker_time_dif = time_dif;
} else {
const w = 1; //weight of new time diff;
const new_dif =
Math.round(((this.worker_time_dif * weight + time_dif * w) / (weight + w)) * precision) / precision;
if (new_dif != this.worker_time_dif) {
// reset the weight so the clock recovers faster from an audio context freeze/dropout if it happens
weight = 4;
}
this.worker_time_dif = new_dif;
}
weight = Math.min(weight + 1, maxWeight);
};
const tickCallback = (payload) => {
const {
num_cycles_at_cps_change,
cps,
num_seconds_at_cps_change,
num_seconds_since_cps_change,
begin,
end,
tickdeadline,
cycle,
} = payload;
this.cps = cps;
this.cycle = cycle;
setTimeReference(num_seconds_at_cps_change, num_seconds_since_cps_change, tickdeadline);
processHaps(begin, end, num_cycles_at_cps_change, num_seconds_at_cps_change);
this.time_at_last_tick_message = this.getTime();
};
const processHaps = (begin, end, num_cycles_at_cps_change, seconds_at_cps_change) => {
if (this.started === false) {
return;
}
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
haps.forEach((hap) => {
if (hap.hasOnset()) {
const targetTime =
(hap.whole.begin - num_cycles_at_cps_change) / this.cps +
seconds_at_cps_change +
this.latency +
this.worker_time_dif;
const duration = hap.duration / this.cps;
onTrigger?.(hap, 0, duration, this.cps, targetTime);
}
});
};
// receive messages from worker clock and process them
this.channel.onmessage = (message) => {
if (!this.started) {
return;
}
const { payload, type } = message.data;
switch (type) {
case 'tick': {
tickCallback(payload);
}
}
};
}
sendMessage(type, payload) {
this.worker.port.postMessage({ type, payload, id: this.id });
}
now() {
const gap = (this.getTime() - this.time_at_last_tick_message) * this.cps;
return this.cycle + gap;
}
setCps(cps = 1) {
this.sendMessage('cpschange', { cps });
}
setCycle(cycle) {
this.sendMessage('setcycle', { cycle });
}
setStarted(started) {
this.sendMessage('toggle', { started });
this.started = started;
this.onToggle?.(started);
}
start() {
logger('[cyclist] start');
this.setStarted(true);
}
stop() {
this.worker_time_dif = null;
logger('[cyclist] stop');
this.setStarted(false);
}
setPattern(pat, autostart = false) {
this.pattern = pat;
if (autostart && !this.started) {
this.start();
}
}
log(begin, end, haps) {
const onsets = haps.filter((h) => h.hasOnset());
console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`);
}
}

View File

@ -1,15 +1,15 @@
{ {
"name": "@strudel.cycles/core", "name": "@strudel/core",
"version": "0.9.0", "version": "1.0.1",
"description": "Port of Tidal Cycles to JavaScript", "description": "Port of Tidal Cycles to JavaScript",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
"main": "dist/index.js", "main": "dist/index.mjs"
"module": "dist/index.mjs"
}, },
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run",
"bench": "vitest bench",
"build": "vite build", "build": "vite build",
"prepublishOnly": "pnpm build" "prepublishOnly": "pnpm build"
}, },
@ -31,11 +31,11 @@
}, },
"homepage": "https://strudel.cc", "homepage": "https://strudel.cc",
"dependencies": { "dependencies": {
"fraction.js": "^4.2.0" "fraction.js": "^4.3.7"
}, },
"gitHead": "0e26d4e741500f5bae35b023608f062a794905c2", "gitHead": "0e26d4e741500f5bae35b023608f062a794905c2",
"devDependencies": { "devDependencies": {
"vite": "^4.3.3", "vite": "^5.0.10",
"vitest": "^0.33.0" "vitest": "^1.1.0"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
import { NeoCyclist } from './neocyclist.mjs';
import { Cyclist } from './cyclist.mjs'; import { Cyclist } from './cyclist.mjs';
import { evaluate as _evaluate } from './evaluate.mjs'; import { evaluate as _evaluate } from './evaluate.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
@ -6,9 +7,7 @@ import { evalScope } from './evaluate.mjs';
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
export function repl({ export function repl({
interval,
defaultOutput, defaultOutput,
onSchedulerError,
onEvalError, onEvalError,
beforeEval, beforeEval,
afterEval, afterEval,
@ -17,6 +16,9 @@ export function repl({
onToggle, onToggle,
editPattern, editPattern,
onUpdateState, onUpdateState,
sync = false,
setInterval,
clearInterval,
}) { }) {
const state = { const state = {
schedulerError: undefined, schedulerError: undefined,
@ -37,21 +39,27 @@ export function repl({
onUpdateState?.(state); onUpdateState?.(state);
}; };
const scheduler = new Cyclist({ const schedulerOptions = {
interval,
onTrigger: getTrigger({ defaultOutput, getTime }), onTrigger: getTrigger({ defaultOutput, getTime }),
onError: onSchedulerError,
getTime, getTime,
onToggle: (started) => { onToggle: (started) => {
updateState({ started }); updateState({ started });
onToggle?.(started); onToggle?.(started);
}, },
}); setInterval,
clearInterval,
};
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
const scheduler =
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
let pPatterns = {}; let pPatterns = {};
let anonymousIndex = 0;
let allTransform; let allTransform;
const hush = function () { const hush = function () {
pPatterns = {}; pPatterns = {};
anonymousIndex = 0;
allTransform = undefined; allTransform = undefined;
return silence; return silence;
}; };
@ -61,12 +69,76 @@ export function repl({
scheduler.setPattern(pattern, autostart); scheduler.setPattern(pattern, autostart);
}; };
setTime(() => scheduler.now()); // TODO: refactor? setTime(() => scheduler.now()); // TODO: refactor?
const stop = () => scheduler.stop();
const start = () => scheduler.start();
const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle();
const setCps = (cps) => scheduler.setCps(cps);
const setCpm = (cpm) => scheduler.setCps(cpm / 60);
const all = function (transform) {
allTransform = transform;
return silence;
};
// set pattern methods that use this repl via closure
const injectPatternMethods = () => {
Pattern.prototype.p = function (id) {
if (id.startsWith('_') || id.endsWith('_')) {
// allows muting a pattern x with x_ or _x
return silence;
}
if (id === '$') {
// allows adding anonymous patterns with $:
id = `$${anonymousIndex}`;
anonymousIndex++;
}
pPatterns[id] = this;
return this;
};
Pattern.prototype.q = function (id) {
return silence;
};
try {
for (let i = 1; i < 10; ++i) {
Object.defineProperty(Pattern.prototype, `d${i}`, {
get() {
return this.p(i);
},
configurable: true,
});
Object.defineProperty(Pattern.prototype, `p${i}`, {
get() {
return this.p(i);
},
configurable: true,
});
Pattern.prototype[`q${i}`] = silence;
}
} catch (err) {
console.warn('injectPatternMethods: error:', err);
}
const cpm = register('cpm', function (cpm, pat) {
return pat._fast(cpm / 60 / scheduler.cps);
});
return evalScope({
all,
hush,
cpm,
setCps,
setcps: setCps,
setCpm,
setcpm: setCpm,
});
};
const evaluate = async (code, autostart = true, shouldHush = true) => { const evaluate = async (code, autostart = true, shouldHush = true) => {
if (!code) { if (!code) {
throw new Error('no code to evaluate'); throw new Error('no code to evaluate');
} }
try { try {
updateState({ code, pending: true }); updateState({ code, pending: true });
await injectPatternMethods();
await beforeEval?.({ code }); await beforeEval?.({ code });
shouldHush && hush(); shouldHush && hush();
let { pattern, meta } = await _evaluate(code, transpiler); let { pattern, meta } = await _evaluate(code, transpiler);
@ -94,88 +166,27 @@ export function repl({
afterEval?.({ code, pattern, meta }); afterEval?.({ code, pattern, meta });
return pattern; return pattern;
} catch (err) { } catch (err) {
// console.warn(`[repl] eval error: ${err.message}`);
logger(`[eval] error: ${err.message}`, 'error'); logger(`[eval] error: ${err.message}`, 'error');
console.error(err);
updateState({ evalError: err, pending: false }); updateState({ evalError: err, pending: false });
onEvalError?.(err); onEvalError?.(err);
} }
}; };
const stop = () => scheduler.stop();
const start = () => scheduler.start();
const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle();
const setCps = (cps) => scheduler.setCps(cps);
const setCpm = (cpm) => scheduler.setCps(cpm / 60);
// the following functions use the cps value, which is why they are defined here..
const loopAt = register('loopAt', (cycles, pat) => {
return pat.loopAtCps(cycles, scheduler.cps);
});
Pattern.prototype.p = function (id) {
pPatterns[id] = this;
return this;
};
Pattern.prototype.q = function (id) {
return silence;
};
const all = function (transform) {
allTransform = transform;
return silence;
};
try {
for (let i = 1; i < 10; ++i) {
Object.defineProperty(Pattern.prototype, `d${i}`, {
get() {
return this.p(i);
},
});
Object.defineProperty(Pattern.prototype, `p${i}`, {
get() {
return this.p(i);
},
});
Pattern.prototype[`q${i}`] = silence;
}
} catch (err) {
// already defined..
}
const fit = register('fit', (pat) =>
pat.withHap((hap) =>
hap.withValue((v) => ({
...v,
speed: scheduler.cps / hap.whole.duration, // overwrite speed completely?
unit: 'c',
})),
),
);
evalScope({
loopAt,
fit,
all,
hush,
setCps,
setcps: setCps,
setCpm,
setcpm: setCpm,
});
const setCode = (code) => updateState({ code }); const setCode = (code) => updateState({ code });
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state }; return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
} }
export const getTrigger = export const getTrigger =
({ getTime, defaultOutput }) => ({ getTime, defaultOutput }) =>
async (hap, deadline, duration, cps) => { async (hap, deadline, duration, cps, t) => {
// TODO: get rid of deadline after https://github.com/tidalcycles/strudel/pull/1004
try { try {
if (!hap.context.onTrigger || !hap.context.dominantTrigger) { if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
await defaultOutput(hap, deadline, duration, cps); await defaultOutput(hap, deadline, duration, cps, t);
} }
if (hap.context.onTrigger) { if (hap.context.onTrigger) {
// call signature of output / onTrigger is different... // call signature of output / onTrigger is different...
await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps); await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps, t);
} }
} catch (err) { } catch (err) {
logger(`[cyclist] error: ${err.message}`, 'error'); logger(`[cyclist] error: ${err.message}`, 'error');

View File

@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { Hap } from './hap.mjs'; import { Hap } from './hap.mjs';
import { Pattern, fastcat, reify, silence, stack, register } from './pattern.mjs'; import { Pattern, fastcat, reify, silence, stack, register } from './pattern.mjs';
import Fraction from './fraction.mjs'; import Fraction from './fraction.mjs';
import { id, _mod, clamp } from './util.mjs'; import { id, _mod, clamp, objectMap } from './util.mjs';
export function steady(value) { export function steady(value) {
// A continuous value // A continuous value
@ -27,9 +27,11 @@ export const isaw2 = isaw.toBipolar();
* *
* @return {Pattern} * @return {Pattern}
* @example * @example
* "c3 [eb3,g3] g2 [g3,bb3]".note().clip(saw.slow(4)) * note("<c3 [eb3,g3] g2 [g3,bb3]>*8")
* .clip(saw.slow(2))
* @example * @example
* saw.range(0,8).segment(8).scale('C major').slow(4).note() * n(saw.range(0,8).segment(8))
* .scale('C major')
* *
*/ */
export const saw = signal((t) => t % 1); export const saw = signal((t) => t % 1);
@ -42,7 +44,8 @@ export const sine2 = signal((t) => Math.sin(Math.PI * 2 * t));
* *
* @return {Pattern} * @return {Pattern}
* @example * @example
* sine.segment(16).range(0,15).slow(2).scale('C minor').note() * n(sine.segment(16).range(0,15))
* .scale("C:minor")
* *
*/ */
export const sine = sine2.fromBipolar(); export const sine = sine2.fromBipolar();
@ -52,7 +55,8 @@ export const sine = sine2.fromBipolar();
* *
* @return {Pattern} * @return {Pattern}
* @example * @example
* stack(sine,cosine).segment(16).range(0,15).slow(2).scale('C minor').note() * n(stack(sine,cosine).segment(16).range(0,15))
* .scale("C:minor")
* *
*/ */
export const cosine = sine._early(Fraction(1).div(4)); export const cosine = sine._early(Fraction(1).div(4));
@ -63,7 +67,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4));
* *
* @return {Pattern} * @return {Pattern}
* @example * @example
* square.segment(2).range(0,7).scale('C minor').note() * n(square.segment(4).range(0,7)).scale("C:minor")
* *
*/ */
export const square = signal((t) => Math.floor((t * 2) % 2)); export const square = signal((t) => Math.floor((t * 2) % 2));
@ -74,7 +78,7 @@ export const square2 = square.toBipolar();
* *
* @return {Pattern} * @return {Pattern}
* @example * @example
* tri.segment(8).range(0,7).scale('C minor').note() * n(tri.segment(8).range(0,7)).scale("C:minor")
* *
*/ */
export const tri = fastcat(isaw, saw); export const tri = fastcat(isaw, saw);
@ -101,6 +105,7 @@ const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x)));
const timeToRandsPrime = (seed, n) => { const timeToRandsPrime = (seed, n) => {
const result = []; const result = [];
// eslint-disable-next-line
for (let i = 0; i < n; ++n) { for (let i = 0; i < n; ++n) {
result.push(intSeedToRand(seed)); result.push(intSeedToRand(seed));
seed = xorwise(seed); seed = xorwise(seed);
@ -117,8 +122,8 @@ const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
/** /**
* A discrete pattern of numbers from 0 to n-1 * A discrete pattern of numbers from 0 to n-1
* @example * @example
* run(4).scale('C4 major').note() * n(run(4)).scale("C4:pentatonic")
* // "0 1 2 3".scale('C4 major').note() * // n("0 1 2 3").scale("C4:pentatonic")
*/ */
export const run = (n) => saw.range(0, n).floor().segment(n); export const run = (n) => saw.range(0, n).floor().segment(n);
@ -128,7 +133,7 @@ export const run = (n) => saw.range(0, n).floor().segment(n);
* @name rand * @name rand
* @example * @example
* // randomly change the cutoff * // randomly change the cutoff
* s("bd sd,hh*4").cutoff(rand.range(500,2000)) * s("bd*4,hh*8").cutoff(rand.range(500,8000))
* *
*/ */
export const rand = signal(timeToRand); export const rand = signal(timeToRand);
@ -138,7 +143,24 @@ export const rand = signal(timeToRand);
export const rand2 = rand.toBipolar(); export const rand2 = rand.toBipolar();
export const _brandBy = (p) => rand.fmap((x) => x < p); export const _brandBy = (p) => rand.fmap((x) => x < p);
/**
* A continuous pattern of 0 or 1 (binary random), with a probability for the value being 1
*
* @name brandBy
* @param {number} probability - a number between 0 and 1
* @example
* s("hh*10").pan(brandBy(0.2))
*/
export const brandBy = (pPat) => reify(pPat).fmap(_brandBy).innerJoin(); export const brandBy = (pPat) => reify(pPat).fmap(_brandBy).innerJoin();
/**
* A continuous pattern of 0 or 1 (binary random)
*
* @name brand
* @example
* s("hh*10").pan(brand)
*/
export const brand = _brandBy(0.5); export const brand = _brandBy(0.5);
export const _irand = (i) => rand.fmap((x) => Math.trunc(x * i)); export const _irand = (i) => rand.fmap((x) => Math.trunc(x * i));
@ -150,36 +172,188 @@ export const _irand = (i) => rand.fmap((x) => Math.trunc(x * i));
* @param {number} n max value (exclusive) * @param {number} n max value (exclusive)
* @example * @example
* // randomly select scale notes from 0 - 7 (= C to C) * // randomly select scale notes from 0 - 7 (= C to C)
* irand(8).struct("x(3,8)").scale('C minor').note() * n(irand(8)).struct("x x*2 x x*3").scale("C:minor")
* *
*/ */
export const irand = (ipat) => reify(ipat).fmap(_irand).innerJoin(); export const irand = (ipat) => reify(ipat).fmap(_irand).innerJoin();
/** const _pick = function (lookup, pat, modulo = true) {
* pick from the list of values (or patterns of values) via the index using the given const array = Array.isArray(lookup);
* pattern of integers const len = Object.keys(lookup).length;
lookup = objectMap(lookup, reify);
if (len === 0) {
return silence;
}
return pat.fmap((i) => {
let key = i;
if (array) {
key = modulo ? Math.round(key) % len : clamp(Math.round(key), 0, lookup.length - 1);
}
return lookup[key];
});
};
/** * Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
* Similar to `inhabit`, but maintains the structure of the original patterns.
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
* @example * @example
* note(pick("<0 1 [2!2] 3>", ["g a", "e f", "f g f g" , "g a c d"])) * note("<0 1 2!2 3>".pick(["g a", "e f", "f g f g" , "g c d"]))
* @example
* sound("<0 1 [2,0]>".pick(["bd sd", "cp cp", "hh hh"]))
* @example
* sound("<0!2 [0,1] 1>".pick(["bd(3,8)", "sd sd"]))
* @example
* s("<a!2 [a,b] b>".pick({a: "bd(3,8)", b: "sd sd"}))
*/ */
export const pick = (pat, xs) => { export const pick = function (lookup, pat) {
xs = xs.map(reify); // backward compatibility - the args used to be flipped
if (xs.length == 0) { if (Array.isArray(pat)) {
return silence; [pat, lookup] = [lookup, pat];
} }
return pat return __pick(lookup, pat);
.fmap((i) => {
const key = clamp(Math.round(i), 0, xs.length - 1);
return xs[key];
})
.innerJoin();
}; };
const __pick = register('pick', function (lookup, pat) {
return _pick(lookup, pat, false).innerJoin();
});
/** * The same as `pick`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value.
* For example, if you pick the fifth pattern of a list of three, you'll get the
* second one.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const pickmod = register('pickmod', function (lookup, pat) {
return _pick(lookup, pat, true).innerJoin();
});
/** * pickF lets you use a pattern of numbers to pick which function to apply to another pattern.
* @param {Pattern} pat
* @param {Pattern} lookup a pattern of indices
* @param {function[]} funcs the array of functions from which to pull
* @returns {Pattern}
* @example
* s("bd [rim hh]").pickF("<0 1 2>", [rev,jux(rev),fast(2)])
* @example
* note("<c2 d2>(3,8)").s("square")
* .pickF("<0 2> 1", [jux(rev),fast(2),x=>x.lpf(800)])
*/
export const pickF = register('pickF', function (lookup, funcs, pat) {
return pat.apply(pick(lookup, funcs));
});
/** * The same as `pickF`, but if you pick a number greater than the size of the functions list,
* it wraps around, rather than sticking at the maximum value.
* @param {Pattern} pat
* @param {Pattern} lookup a pattern of indices
* @param {function[]} funcs the array of functions from which to pull
* @returns {Pattern}
*/
export const pickmodF = register('pickmodF', function (lookup, funcs, pat) {
return pat.apply(pickmod(lookup, funcs));
});
/** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const pickOut = register('pickOut', function (lookup, pat) {
return _pick(lookup, pat, false).outerJoin();
});
/** * The same as `pickOut`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const pickmodOut = register('pickmodOut', function (lookup, pat) {
return _pick(lookup, pat, true).outerJoin();
});
/** * Similar to `pick`, but the choosen pattern is restarted when its index is triggered.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const pickRestart = register('pickRestart', function (lookup, pat) {
return _pick(lookup, pat, false).restartJoin();
});
/** * The same as `pickRestart`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const pickmodRestart = register('pickmodRestart', function (lookup, pat) {
return _pick(lookup, pat, true).restartJoin();
});
/** * Similar to `pick`, but the choosen pattern is reset when its index is triggered.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const pickReset = register('pickReset', function (lookup, pat) {
return _pick(lookup, pat, false).resetJoin();
});
/** * The same as `pickReset`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const pickmodReset = register('pickmodReset', function (lookup, pat) {
return _pick(lookup, pat, true).resetJoin();
});
/** /**
* pick from the list of values (or patterns of values) via the index using the given /** * Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
* Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern.
* @name inhabit
* @synonyms pickSqueeze
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
* @example
* "<a b [a,b]>".inhabit({a: s("bd(3,8)"),
b: s("cp sd")
})
* @example
* s("a@2 [a b] a".inhabit({a: "bd(3,8)", b: "sd sd"})).slow(4)
*/
export const { inhabit, pickSqueeze } = register(['inhabit', 'pickSqueeze'], function (lookup, pat) {
return _pick(lookup, pat, false).squeezeJoin();
});
/** * The same as `inhabit`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value.
* For example, if you pick the fifth pattern of a list of three, you'll get the
* second one.
* @name inhabitmod
* @synonyms pickmodSqueeze
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const { inhabitmod, pickmodSqueeze } = register(['inhabitmod', 'pickmodSqueeze'], function (lookup, pat) {
return _pick(lookup, pat, true).squeezeJoin();
});
/**
* Pick from the list of values (or patterns of values) via the index using the given
* pattern of integers. The selected pattern will be compressed to fit the duration of the selecting event * pattern of integers. The selected pattern will be compressed to fit the duration of the selecting event
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
@ -240,6 +414,8 @@ export const chooseInWith = (pat, xs) => {
* Chooses randomly from the given list of elements. * Chooses randomly from the given list of elements.
* @param {...any} xs values / patterns to choose from. * @param {...any} xs values / patterns to choose from.
* @returns {Pattern} - a continuous pattern. * @returns {Pattern} - a continuous pattern.
* @example
* note("c2 g2!2 d2 f1").s(choose("sine", "triangle", "bd:6"))
*/ */
export const choose = (...xs) => chooseWith(rand, xs); export const choose = (...xs) => chooseWith(rand, xs);
@ -266,11 +442,12 @@ Pattern.prototype.choose2 = function (...xs) {
/** /**
* Picks one of the elements at random each cycle. * Picks one of the elements at random each cycle.
* @synonyms randcat
* @returns {Pattern} * @returns {Pattern}
* @example * @example
* chooseCycles("bd", "hh", "sd").s().fast(4) * chooseCycles("bd", "hh", "sd").s().fast(8)
* @example * @example
* "bd | hh | sd".s().fast(4) * s("bd | hh | sd").fast(8)
*/ */
export const chooseCycles = (...xs) => chooseInWith(rand.segment(1), xs); export const chooseCycles = (...xs) => chooseInWith(rand.segment(1), xs);
@ -294,9 +471,27 @@ const _wchooseWith = function (pat, ...pairs) {
const wchooseWith = (...args) => _wchooseWith(...args).outerJoin(); const wchooseWith = (...args) => _wchooseWith(...args).outerJoin();
/**
* Chooses randomly from the given list of elements by giving a probability to each element
* @param {...any} pairs arrays of value and weight
* @returns {Pattern} - a continuous pattern.
* @example
* note("c2 g2!2 d2 f1").s(wchoose(["sine",10], ["triangle",1], ["bd:6",1]))
*/
export const wchoose = (...pairs) => wchooseWith(rand, ...pairs); export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
export const wchooseCycles = (...pairs) => _wchooseWith(rand, ...pairs).innerJoin(); /**
* Picks one of the elements at random each cycle by giving a probability to each element
* @synonyms wrandcat
* @returns {Pattern}
* @example
* wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8)
* @example
* wchooseCycles(["bd bd bd",5], ["hh hh hh",3], ["sd sd sd",1]).fast(4).s()
*/
export const wchooseCycles = (...pairs) => _wchooseWith(rand.segment(1), ...pairs).innerJoin();
export const wrandcat = wchooseCycles;
// this function expects pat to be a pattern of floats... // this function expects pat to be a pattern of floats...
export const perlinWith = (pat) => { export const perlinWith = (pat) => {
@ -313,7 +508,7 @@ export const perlinWith = (pat) => {
* @name perlin * @name perlin
* @example * @example
* // randomly change the cutoff * // randomly change the cutoff
* s("bd sd,hh*4").cutoff(perlin.range(500,2000)) * s("bd*4,hh*8").cutoff(perlin.range(500,8000))
* *
*/ */
export const perlin = perlinWith(time.fmap((v) => Number(v))); export const perlin = perlinWith(time.fmap((v) => Number(v)));
@ -355,7 +550,7 @@ export const degradeBy = register('degradeBy', function (x, pat) {
export const degrade = register('degrade', (pat) => pat._degradeBy(0.5)); export const degrade = register('degrade', (pat) => pat._degradeBy(0.5));
/** /**
* Inverse of {@link Pattern#degradeBy}: Randomly removes events from the pattern by a given amount. * Inverse of `degradeBy`: Randomly removes events from the pattern by a given amount.
* 0 = 100% chance of removal * 0 = 100% chance of removal
* 1 = 0% chance of removal * 1 = 0% chance of removal
* Events that would be removed by degradeBy are let through by undegradeBy and vice versa (see second example). * Events that would be removed by degradeBy are let through by undegradeBy and vice versa (see second example).
@ -366,6 +561,11 @@ export const degrade = register('degrade', (pat) => pat._degradeBy(0.5));
* @returns Pattern * @returns Pattern
* @example * @example
* s("hh*8").undegradeBy(0.2) * s("hh*8").undegradeBy(0.2)
* @example
* s("hh*10").layer(
* x => x.degradeBy(0.2).pan(0),
* x => x.undegradeBy(0.8).pan(1)
* )
*/ */
export const undegradeBy = register('undegradeBy', function (x, pat) { export const undegradeBy = register('undegradeBy', function (x, pat) {
return pat._degradeByWith( return pat._degradeByWith(
@ -374,12 +574,27 @@ export const undegradeBy = register('undegradeBy', function (x, pat) {
); );
}); });
/**
* Inverse of `degrade`: Randomly removes 50% of events from the pattern. Shorthand for `.undegradeBy(0.5)`
* Events that would be removed by degrade are let through by undegrade and vice versa (see second example).
*
* @name undegrade
* @memberof Pattern
* @returns Pattern
* @example
* s("hh*8").undegrade()
* @example
* s("hh*10").layer(
* x => x.degrade().pan(0),
* x => x.undegrade().pan(1)
* )
*/
export const undegrade = register('undegrade', (pat) => pat._undegradeBy(0.5)); export const undegrade = register('undegrade', (pat) => pat._undegradeBy(0.5));
/** /**
* *
* Randomly applies the given function by the given probability. * Randomly applies the given function by the given probability.
* Similar to {@link Pattern#someCyclesBy} * Similar to `someCyclesBy`
* *
* @name sometimesBy * @name sometimesBy
* @memberof Pattern * @memberof Pattern
@ -387,7 +602,7 @@ export const undegrade = register('undegrade', (pat) => pat._undegradeBy(0.5));
* @param {function} function - the transformation to apply * @param {function} function - the transformation to apply
* @returns Pattern * @returns Pattern
* @example * @example
* s("hh(3,8)").sometimesBy(.4, x=>x.speed("0.5")) * s("hh*8").sometimesBy(.4, x=>x.speed("0.5"))
*/ */
export const sometimesBy = register('sometimesBy', function (patx, func, pat) { export const sometimesBy = register('sometimesBy', function (patx, func, pat) {
@ -405,7 +620,7 @@ export const sometimesBy = register('sometimesBy', function (patx, func, pat) {
* @param {function} function - the transformation to apply * @param {function} function - the transformation to apply
* @returns Pattern * @returns Pattern
* @example * @example
* s("hh*4").sometimes(x=>x.speed("0.5")) * s("hh*8").sometimes(x=>x.speed("0.5"))
*/ */
export const sometimes = register('sometimes', function (func, pat) { export const sometimes = register('sometimes', function (func, pat) {
return pat._sometimesBy(0.5, func); return pat._sometimesBy(0.5, func);
@ -414,7 +629,7 @@ export const sometimes = register('sometimes', function (func, pat) {
/** /**
* *
* Randomly applies the given function by the given probability on a cycle by cycle basis. * Randomly applies the given function by the given probability on a cycle by cycle basis.
* Similar to {@link Pattern#sometimesBy} * Similar to `sometimesBy`
* *
* @name someCyclesBy * @name someCyclesBy
* @memberof Pattern * @memberof Pattern
@ -422,7 +637,7 @@ export const sometimes = register('sometimes', function (func, pat) {
* @param {function} function - the transformation to apply * @param {function} function - the transformation to apply
* @returns Pattern * @returns Pattern
* @example * @example
* s("hh(3,8)").someCyclesBy(.3, x=>x.speed("0.5")) * s("bd,hh*8").someCyclesBy(.3, x=>x.speed("0.5"))
*/ */
export const someCyclesBy = register('someCyclesBy', function (patx, func, pat) { export const someCyclesBy = register('someCyclesBy', function (patx, func, pat) {
@ -444,7 +659,7 @@ export const someCyclesBy = register('someCyclesBy', function (patx, func, pat)
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
* @example * @example
* s("hh(3,8)").someCycles(x=>x.speed("0.5")) * s("bd,hh*8").someCycles(x=>x.speed("0.5"))
*/ */
export const someCycles = register('someCycles', function (func, pat) { export const someCycles = register('someCycles', function (func, pat) {
return pat._someCyclesBy(0.5, func); return pat._someCyclesBy(0.5, func);

View File

@ -4,25 +4,39 @@ Copyright (C) 2023 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/>. 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 controls from '../controls.mjs'; import { s, pan } from '../controls.mjs';
import { mini } from '../../mini/mini.mjs'; import { mini } from '../../mini/mini.mjs';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import Fraction from '../fraction.mjs';
describe('controls', () => { describe('controls', () => {
it('should support controls', () => { it('should support controls', () => {
expect(controls.s('bd').firstCycleValues).toEqual([{ s: 'bd' }]); expect(s('bd').firstCycleValues).toEqual([{ s: 'bd' }]);
}); });
it('should support compound controls', () => { it('should support compound controls', () => {
expect(controls.s(mini('bd:3')).firstCycleValues).toEqual([{ s: 'bd', n: 3 }]); expect(s(mini('bd:3')).firstCycleValues).toEqual([{ s: 'bd', n: 3 }]);
expect(controls.s(mini('bd:3 sd:4:1.4')).firstCycleValues).toEqual([ expect(s(mini('bd:3 sd:4:1.4')).firstCycleValues).toEqual([
{ s: 'bd', n: 3 }, { s: 'bd', n: 3 },
{ s: 'sd', n: 4, gain: 1.4 }, { s: 'sd', n: 4, gain: 1.4 },
]); ]);
}); });
it('should support ignore extra elements in compound controls', () => { it('should support ignore extra elements in compound controls', () => {
expect(controls.s(mini('bd:3:0.4 sd:4:0.5:3:17')).firstCycleValues).toEqual([ expect(s(mini('bd:3:0.4 sd:4:0.5:3:17')).firstCycleValues).toEqual([
{ s: 'bd', n: 3, gain: 0.4 }, { s: 'bd', n: 3, gain: 0.4 },
{ s: 'sd', n: 4, gain: 0.5 }, { s: 'sd', n: 4, gain: 0.5 },
]); ]);
}); });
it('should support nested controls', () => {
expect(s(mini('bd').pan(1)).firstCycleValues).toEqual([{ s: 'bd', pan: 1 }]);
expect(s(mini('bd:1').pan(1)).firstCycleValues).toEqual([{ s: 'bd', n: 1, pan: 1 }]);
});
it('preserves tactus of the left pattern', () => {
expect(s(mini('bd cp mt').pan(mini('1 2 3 4'))).tactus).toEqual(Fraction(3));
});
it('preserves tactus of the right pattern for .out', () => {
expect(s(mini('bd cp mt').set.out(pan(mini('1 2 3 4')))).tactus).toEqual(Fraction(4));
});
it('combines tactus of the pattern for .mix as lcm', () => {
expect(s(mini('bd cp mt').set.mix(pan(mini('1 2 3 4')))).tactus).toEqual(Fraction(12));
});
}); });

View File

@ -21,8 +21,8 @@ import {
cat, cat,
sequence, sequence,
palindrome, palindrome,
polymeter, s_polymeter,
polymeterSteps, s_polymeterSteps,
polyrhythm, polyrhythm,
silence, silence,
fast, fast,
@ -46,13 +46,18 @@ import {
rev, rev,
time, time,
run, run,
pick,
stackLeft,
stackRight,
stackCentre,
s_cat,
calculateTactus,
} from '../index.mjs'; } from '../index.mjs';
import { steady } from '../signal.mjs'; import { steady } from '../signal.mjs';
import controls from '../controls.mjs'; import { n, s } from '../controls.mjs';
const { n, s } = controls;
const st = (begin, end) => new State(ts(begin, end)); const st = (begin, end) => new State(ts(begin, end));
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end)); const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
const hap = (whole, part, value, context = {}) => new Hap(whole, part, value, context); const hap = (whole, part, value, context = {}) => new Hap(whole, part, value, context);
@ -181,15 +186,19 @@ describe('Pattern', () => {
new Hap(ts(1 / 2, 2 / 3), ts(1 / 2, 2 / 3), 7), new Hap(ts(1 / 2, 2 / 3), ts(1 / 2, 2 / 3), 7),
]); ]);
}); });
it('can Trig() structure', () => { it('can Reset() structure', () => {
sameFirst( sameFirst(
slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10).add.trig(20, 30).early(2), slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10)
.add.reset(20, 30)
.early(2),
sequence(26, 27, 36, 37), sequence(26, 27, 36, 37),
); );
}); });
it('can Trigzero() structure', () => { it('can Restart() structure', () => {
sameFirst( sameFirst(
slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10).add.trigzero(20, 30).early(2), slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10)
.add.restart(20, 30)
.early(2),
sequence(21, 22, 31, 32), sequence(21, 22, 31, 32),
); );
}); });
@ -229,15 +238,19 @@ describe('Pattern', () => {
new Hap(ts(1 / 2, 2 / 3), ts(1 / 2, 2 / 3), 2), new Hap(ts(1 / 2, 2 / 3), ts(1 / 2, 2 / 3), 2),
]); ]);
}); });
it('can Trig() structure', () => { it('can Reset() structure', () => {
sameFirst( sameFirst(
slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10).keep.trig(20, 30).early(2), slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10)
.keep.reset(20, 30)
.early(2),
sequence(6, 7, 6, 7), sequence(6, 7, 6, 7),
); );
}); });
it('can Trigzero() structure', () => { it('can Restart() structure', () => {
sameFirst( sameFirst(
slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10).keep.trigzero(20, 30).early(2), slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10)
.keep.restart(20, 30)
.early(2),
sequence(1, 2, 1, 2), sequence(1, 2, 1, 2),
); );
}); });
@ -271,15 +284,19 @@ describe('Pattern', () => {
new Hap(ts(1 / 2, 2 / 3), ts(1 / 2, 2 / 3), 2), new Hap(ts(1 / 2, 2 / 3), ts(1 / 2, 2 / 3), 2),
]); ]);
}); });
it('can Trig() structure', () => { it('can Reset() structure', () => {
sameFirst( sameFirst(
slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10).keepif.trig(false, true).early(2), slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10)
.keepif.reset(false, true)
.early(2),
sequence(silence, silence, 6, 7), sequence(silence, silence, 6, 7),
); );
}); });
it('can Trigzero() structure', () => { it('can Restart() structure', () => {
sameFirst( sameFirst(
slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10).keepif.trigzero(false, true).early(2), slowcat(sequence(1, 2, 3, 4), 5, sequence(6, 7, 8, 9), 10)
.keepif.restart(false, true)
.early(2),
sequence(silence, silence, 1, 2), sequence(silence, silence, 1, 2),
); );
}); });
@ -591,16 +608,19 @@ describe('Pattern', () => {
); );
}); });
}); });
describe('polymeter()', () => { describe('s_polymeter()', () => {
it('Can layer up cycles, stepwise', () => { it('Can layer up cycles, stepwise, with lists', () => {
expect(polymeterSteps(3, ['d', 'e']).firstCycle()).toStrictEqual( expect(s_polymeterSteps(3, ['d', 'e']).firstCycle()).toStrictEqual(
fastcat(pure('d'), pure('e'), pure('d')).firstCycle(), fastcat(pure('d'), pure('e'), pure('d')).firstCycle(),
); );
expect(polymeter(['a', 'b', 'c'], ['d', 'e']).fast(2).firstCycle()).toStrictEqual( expect(s_polymeter(['a', 'b', 'c'], ['d', 'e']).fast(2).firstCycle()).toStrictEqual(
stack(sequence('a', 'b', 'c', 'a', 'b', 'c'), sequence('d', 'e', 'd', 'e', 'd', 'e')).firstCycle(), stack(sequence('a', 'b', 'c', 'a', 'b', 'c'), sequence('d', 'e', 'd', 'e', 'd', 'e')).firstCycle(),
); );
}); });
it('Can layer up cycles, stepwise, with weighted patterns', () => {
sameFirst(s_polymeterSteps(3, sequence('a', 'b')).fast(2), sequence('a', 'b', 'a', 'b', 'a', 'b'));
});
}); });
describe('firstOf()', () => { describe('firstOf()', () => {
@ -651,7 +671,11 @@ describe('Pattern', () => {
}); });
describe('struct()', () => { describe('struct()', () => {
it('Can restructure a discrete pattern', () => { it('Can restructure a discrete pattern', () => {
expect(sequence('a', 'b').struct(sequence(true, true, true)).firstCycle()).toStrictEqual([ expect(
sequence('a', 'b')
.struct(sequence(true, true, true))
.firstCycle(),
).toStrictEqual([
hap(ts(0, third), ts(0, third), 'a'), hap(ts(0, third), ts(0, third), 'a'),
hap(ts(third, twothirds), ts(third, 0.5), 'a'), hap(ts(third, twothirds), ts(third, 0.5), 'a'),
hap(ts(third, twothirds), ts(0.5, twothirds), 'b'), hap(ts(third, twothirds), ts(0.5, twothirds), 'b'),
@ -682,7 +706,11 @@ describe('Pattern', () => {
}); });
describe('mask()', () => { describe('mask()', () => {
it('Can fragment a pattern', () => { it('Can fragment a pattern', () => {
expect(sequence('a', 'b').mask(sequence(true, true, true)).firstCycle()).toStrictEqual([ expect(
sequence('a', 'b')
.mask(sequence(true, true, true))
.firstCycle(),
).toStrictEqual([
hap(ts(0, 0.5), ts(0, third), 'a'), hap(ts(0, 0.5), ts(0, third), 'a'),
hap(ts(0, 0.5), ts(third, 0.5), 'a'), hap(ts(0, 0.5), ts(third, 0.5), 'a'),
hap(ts(0.5, 1), ts(0.5, twothirds), 'b'), hap(ts(0.5, 1), ts(0.5, twothirds), 'b'),
@ -951,9 +979,11 @@ describe('Pattern', () => {
expect(stack(pure('a').mask(1, 0), pure('a').mask(0, 1)).defragmentHaps().firstCycle().length).toStrictEqual(1); expect(stack(pure('a').mask(1, 0), pure('a').mask(0, 1)).defragmentHaps().firstCycle().length).toStrictEqual(1);
}); });
it('Doesnt merge two overlapping haps', () => { it('Doesnt merge two overlapping haps', () => {
expect(stack(pure('a').mask(1, 1, 0), pure('a').mask(0, 1)).defragmentHaps().firstCycle().length).toStrictEqual( expect(
2, stack(pure('a').mask(1, 1, 0), pure('a').mask(0, 1))
); .defragmentHaps()
.firstCycle().length,
).toStrictEqual(2);
}); });
it('Doesnt merge two touching haps with different values', () => { it('Doesnt merge two touching haps with different values', () => {
expect(stack(pure('a').mask(1, 0), pure('b').mask(0, 1)).defragmentHaps().firstCycle().length).toStrictEqual(2); expect(stack(pure('a').mask(1, 0), pure('b').mask(0, 1)).defragmentHaps().firstCycle().length).toStrictEqual(2);
@ -1035,4 +1065,148 @@ describe('Pattern', () => {
expect(slowcat(0, 1).repeatCycles(2).fast(6).firstCycleValues).toStrictEqual([0, 0, 1, 1, 0, 0]); expect(slowcat(0, 1).repeatCycles(2).fast(6).firstCycleValues).toStrictEqual([0, 0, 1, 1, 0, 0]);
}); });
}); });
describe('inhabit', () => {
it('Can pattern named patterns', () => {
expect(
sameFirst(
sequence('a', 'b', stack('a', 'b')).inhabit({ a: sequence(1, 2), b: sequence(10, 20, 30) }),
sequence([1, 2], [10, 20, 30], stack([1, 2], [10, 20, 30])),
),
);
});
it('Can pattern indexed patterns', () => {
expect(
sameFirst(
sequence('0', '1', stack('0', '1')).inhabit([sequence(1, 2), sequence(10, 20, 30)]),
sequence([1, 2], [10, 20, 30], stack([1, 2], [10, 20, 30])),
),
);
});
});
describe('pick', () => {
it('Can pattern named patterns', () => {
expect(
sameFirst(
sequence('a', 'b', 'a', stack('a', 'b')).pick({ a: sequence(1, 2, 3, 4), b: sequence(10, 20, 30, 40) }),
sequence(1, 20, 3, stack(4, 40)),
),
);
});
it('Can pattern indexed patterns', () => {
expect(
sameFirst(
sequence(0, 1, 0, stack(0, 1)).pick([sequence(1, 2, 3, 4), sequence(10, 20, 30, 40)]),
sequence(1, 20, 3, stack(4, 40)),
),
);
});
it('Clamps indexes', () => {
expect(
sameFirst(sequence(0, 1, 2, 3).pick([sequence(1, 2, 3, 4), sequence(10, 20, 30, 40)]), sequence(1, 20, 30, 40)),
);
});
it('Is backwards compatible', () => {
expect(
sameFirst(
pick([sequence('a', 'b'), sequence('c', 'd')], sequence(0, 1)),
pick(sequence(0, 1), [sequence('a', 'b'), sequence('c', 'd')]),
),
);
});
});
describe('pickmod', () => {
it('Wraps indexes', () => {
expect(
sameFirst(
sequence(0, 1, 2, 3).pickmod([sequence(1, 2, 3, 4), sequence(10, 20, 30, 40)]),
sequence(1, 20, 3, 40),
),
);
});
});
describe('tactus', () => {
it('Is correctly preserved/calculated through transformations', () => {
expect(sequence(0, 1, 2, 3).linger(4).tactus).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).iter(4).tactus).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).fast(4).tactus).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).hurry(4).tactus).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).rev().tactus).toStrictEqual(Fraction(4));
expect(sequence(1).segment(10).tactus).toStrictEqual(Fraction(10));
expect(sequence(1, 0, 1).invert().tactus).toStrictEqual(Fraction(3));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).chop(4).tactus).toStrictEqual(Fraction(8));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).striate(4).tactus).toStrictEqual(Fraction(8));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).slice(4, sequence(0, 1, 2, 3)).tactus).toStrictEqual(
Fraction(4),
);
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).splice(4, sequence(0, 1, 2, 3)).tactus).toStrictEqual(
Fraction(4),
);
expect(sequence({ n: 0 }, { n: 1 }, { n: 2 }).chop(4).tactus).toStrictEqual(Fraction(12));
expect(
pure((x) => x + 1)
.setTactus(3)
.appBoth(pure(1).setTactus(2)).tactus,
).toStrictEqual(Fraction(6));
expect(
pure((x) => x + 1)
.setTactus(undefined)
.appBoth(pure(1).setTactus(2)).tactus,
).toStrictEqual(Fraction(2));
expect(
pure((x) => x + 1)
.setTactus(3)
.appBoth(pure(1).setTactus(undefined)).tactus,
).toStrictEqual(Fraction(3));
expect(stack(fastcat(0, 1, 2), fastcat(3, 4)).tactus).toStrictEqual(Fraction(6));
expect(stack(fastcat(0, 1, 2), fastcat(3, 4).setTactus(undefined)).tactus).toStrictEqual(Fraction(3));
expect(stackLeft(fastcat(0, 1, 2, 3), fastcat(3, 4)).tactus).toStrictEqual(Fraction(4));
expect(stackRight(fastcat(0, 1, 2), fastcat(3, 4)).tactus).toStrictEqual(Fraction(3));
// maybe this should double when they are either all even or all odd
expect(stackCentre(fastcat(0, 1, 2), fastcat(3, 4)).tactus).toStrictEqual(Fraction(3));
expect(fastcat(0, 1).ply(3).tactus).toStrictEqual(Fraction(6));
expect(fastcat(0, 1).setTactus(undefined).ply(3).tactus).toStrictEqual(undefined);
expect(fastcat(0, 1).fast(3).tactus).toStrictEqual(Fraction(2));
expect(fastcat(0, 1).setTactus(undefined).fast(3).tactus).toStrictEqual(undefined);
});
});
describe('s_cat', () => {
it('can cat', () => {
expect(sameFirst(s_cat(fastcat(0, 1, 2, 3), fastcat(4, 5)), fastcat(0, 1, 2, 3, 4, 5)));
expect(sameFirst(s_cat(pure(1), pure(2), pure(3)), fastcat(1, 2, 3)));
});
it('calculates undefined tactuses as the average', () => {
expect(sameFirst(s_cat(pure(1), pure(2), pure(3).setTactus(undefined)), fastcat(1, 2, 3)));
});
});
describe('s_taper', () => {
it('can taper', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).s_taper(1, 5), sequence(0, 1, 2, 3, 4, 0, 1, 2, 3, 0, 1, 2, 0, 1, 0)));
});
it('can taper backwards', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).s_taper(-1, 5), sequence(0, 0, 1, 0, 1, 2, 0, 1, 2, 3, 0, 1, 2, 3, 4)));
});
});
describe('s_add and s_sub', () => {
it('can add from the left', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).s_add(2), sequence(0, 1)));
});
it('can sub to the left', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).s_sub(2), sequence(0, 1, 2)));
});
it('can add from the right', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).s_add(-2), sequence(3, 4)));
});
it('can sub to the right', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).s_sub(-2), sequence(2, 3, 4)));
});
it('can subtract nothing', () => {
expect(sameFirst(pure('a').s_sub(0), pure('a')));
});
it('can subtract nothing, repeatedly', () => {
expect(sameFirst(pure('a').s_sub(0, 0), fastcat('a', 'a')));
for (var i = 0; i < 100; ++i) {
expect(sameFirst(pure('a').s_sub(...Array(i).fill(0)), fastcat(...Array(i).fill('a'))));
}
});
});
}); });

View File

@ -6,8 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { map, valued, mul } from '../value.mjs'; import { map, valued, mul } from '../value.mjs';
import controls from '../controls.mjs'; import { n } from '../controls.mjs';
const { n } = controls;
describe('Value', () => { describe('Value', () => {
it('unionWith', () => { it('unionWith', () => {
@ -23,8 +22,4 @@ describe('Value', () => {
expect(valued(mul).ap(3).ap(3).value).toEqual(9); expect(valued(mul).ap(3).ap(3).value).toEqual(9);
expect(valued(3).mul(3).value).toEqual(9); expect(valued(3).mul(3).value).toEqual(9);
}); });
it('union bare numbers for numeral props', () => {
expect(n(3).cutoff(500).add(10).firstCycleValues).toEqual([{ n: 13, cutoff: 510 }]);
expect(n(3).cutoff(500).mul(2).firstCycleValues).toEqual([{ n: 6, cutoff: 1000 }]);
});
}); });

View File

@ -4,19 +4,6 @@ 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/>. 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 { getTime } from './time.mjs';
function frame(callback) {
if (window.strudelAnimation) {
cancelAnimationFrame(window.strudelAnimation);
}
const animate = (animationTime) => {
callback(animationTime, getTime());
window.strudelAnimation = requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}
export const backgroundImage = function (src, animateOptions = {}) { export const backgroundImage = function (src, animateOptions = {}) {
const container = document.getElementById('code'); const container = document.getElementById('code');
const bg = 'background-image:url(' + src + ');background-size:contain;'; const bg = 'background-image:url(' + src + ');background-size:contain;';
@ -35,11 +22,6 @@ export const backgroundImage = function (src, animateOptions = {}) {
if (funcOptions.length === 0) { if (funcOptions.length === 0) {
return; return;
} }
frame((_, t) =>
funcOptions.forEach(([option, value]) => {
handleOption(option, value(t));
}),
);
}; };
export const cleanupUi = () => { export const cleanupUi = () => {

View File

@ -4,6 +4,8 @@ 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/>. 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 { logger } from './logger.mjs';
// returns true if the given string is a note // returns true if the given string is a note
export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name); export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name);
export const isNote = (name) => /^[a-gA-G][#bsf]*[0-9]?$/.test(name); export const isNote = (name) => /^[a-gA-G][#bsf]*[0-9]?$/.test(name);
@ -59,6 +61,11 @@ export const valueToMidi = (value, fallbackValue) => {
return fallbackValue; return fallbackValue;
}; };
// used to schedule external event like midi and osc out
export const getEventOffsetMs = (targetTimeSeconds, currentTimeSeconds) => {
return (targetTimeSeconds - currentTimeSeconds) * 1000;
};
/** /**
* @deprecated does not appear to be referenced or invoked anywhere in the codebase * @deprecated does not appear to be referenced or invoked anywhere in the codebase
* @noAutocomplete * @noAutocomplete
@ -84,6 +91,18 @@ export const midi2note = (n) => {
// modulo that works with negative numbers e.g. _mod(-1, 3) = 2. Works on numbers (rather than patterns of numbers, as @mod@ from pattern.mjs does) // modulo that works with negative numbers e.g. _mod(-1, 3) = 2. Works on numbers (rather than patterns of numbers, as @mod@ from pattern.mjs does)
export const _mod = (n, m) => ((n % m) + m) % m; export const _mod = (n, m) => ((n % m) + m) % m;
export function nanFallback(value, fallback = 0) {
if (isNaN(Number(value))) {
logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning');
return fallback;
}
return value;
}
// round to nearest int, negative numbers will output a subtracted index
export const getSoundIndex = (n, numSounds) => {
return _mod(Math.round(nanFallback(n ?? 0, 0)), numSounds);
};
export const getPlayableNoteValue = (hap) => { export const getPlayableNoteValue = (hap) => {
let { value, context } = hap; let { value, context } = hap;
let note = value; let note = value;
@ -217,6 +236,14 @@ export const splitAt = function (index, value) {
export const zipWith = (f, xs, ys) => xs.map((n, i) => f(n, ys[i])); export const zipWith = (f, xs, ys) => xs.map((n, i) => f(n, ys[i]));
export const pairs = function (xs) {
const result = [];
for (let i = 0; i < xs.length - 1; ++i) {
result.push([xs[i], xs[i + 1]]);
}
return result;
};
export const clamp = (num, min, max) => Math.min(Math.max(num, min), max); export const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
/* solmization, not used yet */ /* solmization, not used yet */
@ -262,19 +289,43 @@ export const sol2note = (n, notation = 'letters') => {
notation === 'solfeggio' notation === 'solfeggio'
? solfeggio /*check if its is any of the following*/ ? solfeggio /*check if its is any of the following*/
: notation === 'indian' : notation === 'indian'
? indian ? indian
: notation === 'german' : notation === 'german'
? german ? german
: notation === 'byzantine' : notation === 'byzantine'
? byzantine ? byzantine
: notation === 'japanese' : notation === 'japanese'
? japanese ? japanese
: english; /*if not use standard version*/ : english; /*if not use standard version*/
const note = pc[n % 12]; /*calculating the midi value to the note*/ const note = pc[n % 12]; /*calculating the midi value to the note*/
const oct = Math.floor(n / 12) - 1; const oct = Math.floor(n / 12) - 1;
return note + oct; return note + oct;
}; };
// Remove duplicates from list
export function uniq(a) {
var seen = {};
return a.filter(function (item) {
return seen.hasOwn(item) ? false : (seen[item] = true);
});
}
// Remove duplicates from list, sorting in the process. Mutates argument!
export function uniqsort(a) {
return a.sort().filter(function (item, pos, ary) {
return !pos || item != ary[pos - 1];
});
}
// rational version
export function uniqsortr(a) {
return a
.sort((x, y) => x.compare(y))
.filter(function (item, pos, ary) {
return !pos || item.ne(ary[pos - 1]);
});
}
// code hashing helpers // code hashing helpers
export function unicodeToBase64(text) { export function unicodeToBase64(text) {
@ -302,3 +353,30 @@ export function hash2code(hash) {
return base64ToUnicode(decodeURIComponent(hash)); return base64ToUnicode(decodeURIComponent(hash));
//return atob(decodeURIComponent(codeParam || '')); //return atob(decodeURIComponent(codeParam || ''));
} }
export function objectMap(obj, fn) {
if (Array.isArray(obj)) {
return obj.map(fn);
}
return Object.fromEntries(Object.entries(obj).map(([k, v], i) => [k, fn(v, k, i)]));
}
// Floating point versions, see Fraction for rational versions
// // greatest common divisor
// export const gcd = function (x, y, ...z) {
// if (!y && z.length > 0) {
// return gcd(x, ...z);
// }
// if (!y) {
// return x;
// }
// return gcd(y, x % y, ...z);
// };
// // lowest common multiple
// export const lcm = function (x, y, ...z) {
// if (z.length == 0) {
// return (x * y) / gcd(x, y);
// }
// return lcm((x * y) / gcd(x, y), ...z);
// };

View File

@ -5,14 +5,13 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import { curry } from './util.mjs'; import { curry } from './util.mjs';
import { logger } from './logger.mjs';
export function unionWithObj(a, b, func) { export function unionWithObj(a, b, func) {
if (typeof b?.value === 'number') { if (b?.value !== undefined && Object.keys(b).length === 1) {
// https://github.com/tidalcycles/strudel/issues/262 // https://github.com/tidalcycles/strudel/issues/1026
const numKeys = Object.keys(a).filter((k) => typeof a[k] === 'number'); logger(`[warn]: Can't do arithmetic on control pattern.`);
const numerals = Object.fromEntries(numKeys.map((k) => [k, b.value])); return a;
b = Object.assign(b, numerals);
delete b.value;
} }
const common = Object.keys(a).filter((k) => Object.keys(b).includes(k)); const common = Object.keys(a).filter((k) => Object.keys(b).includes(k));
return Object.assign({}, a, b, Object.fromEntries(common.map((k) => [k, func(a[k], b[k])]))); return Object.assign({}, a, b, Object.fromEntries(common.map((k) => [k, func(a[k], b[k])])));

View File

@ -8,8 +8,8 @@ export default defineConfig({
build: { build: {
lib: { lib: {
entry: resolve(__dirname, 'index.mjs'), entry: resolve(__dirname, 'index.mjs'),
formats: ['es', 'cjs'], formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.js' }[ext]), fileName: (ext) => ({ es: 'index.mjs' })[ext],
}, },
rollupOptions: { rollupOptions: {
external: [...Object.keys(dependencies)], external: [...Object.keys(dependencies)],

View File

@ -7,6 +7,9 @@ function createClock(
duration = 0.05, // duration of each cycle duration = 0.05, // duration of each cycle
interval = 0.1, // interval between callbacks interval = 0.1, // interval between callbacks
overlap = 0.1, // overlap between callbacks overlap = 0.1, // overlap between callbacks
setInterval = globalThis.setInterval,
clearInterval = globalThis.clearInterval,
round = true,
) { ) {
let tick = 0; // counts callbacks let tick = 0; // counts callbacks
let phase = 0; // next callback time let phase = 0; // next callback time
@ -22,9 +25,8 @@ function createClock(
} }
// callback as long as we're inside the lookahead // callback as long as we're inside the lookahead
while (phase < lookahead) { while (phase < lookahead) {
phase = Math.round(phase * precision) / precision; phase = round ? Math.round(phase * precision) / precision : phase;
phase >= t && callback(phase, duration, tick); callback(phase, duration, tick, t); // callback has to skip / handle phase < t!
phase < t && console.log('TOO LATE', phase); // what if latency is added from outside?
phase += duration; // increment phase by duration phase += duration; // increment phase by duration
tick++; tick++;
} }
@ -35,7 +37,10 @@ function createClock(
onTick(); onTick();
intervalID = setInterval(onTick, interval * 1000); intervalID = setInterval(onTick, interval * 1000);
}; };
const clear = () => intervalID !== undefined && clearInterval(intervalID); const clear = () => {
intervalID !== undefined && clearInterval(intervalID);
intervalID = undefined;
};
const pause = () => clear(); const pause = () => clear();
const stop = () => { const stop = () => {
tick = 0; tick = 0;

View File

@ -0,0 +1 @@
# @strudel/csound

View File

@ -1,5 +1,5 @@
import { getFrequency, logger, register } from '@strudel.cycles/core'; import { getFrequency, logger, register } from '@strudel/core';
import { getAudioContext } from '@strudel.cycles/webaudio'; import { getAudioContext } from '@strudel/webaudio';
import csd from './project.csd?raw'; import csd from './project.csd?raw';
// import livecodeOrc from './livecode.orc?raw'; // import livecodeOrc from './livecode.orc?raw';
import presetsOrc from './presets.orc?raw'; import presetsOrc from './presets.orc?raw';
@ -23,7 +23,7 @@ export const csound = register('csound', (instrument, pat) => {
instrument = instrument || 'triangle'; instrument = instrument || 'triangle';
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
// TODO: find a alternative way to wait for csound to load (to wait with first time playback) // TODO: find a alternative way to wait for csound to load (to wait with first time playback)
return pat.onTrigger((time, hap) => { return pat.onTrigger((time_deprecate, hap, currentTime, _cps, targetTime) => {
if (!_csound) { if (!_csound) {
logger('[csound] not loaded yet', 'warning'); logger('[csound] not loaded yet', 'warning');
return; return;
@ -38,9 +38,11 @@ export const csound = register('csound', (instrument, pat) => {
.join('/'); .join('/');
// TODO: find out how to send a precise ctx based time // TODO: find out how to send a precise ctx based time
// http://www.csounds.com/manual/html/i.html // http://www.csounds.com/manual/html/i.html
const timeOffset = targetTime - currentTime; // latency ?
//const timeOffset = time_deprecate - getAudioContext().currentTime
const params = [ const params = [
`"${instrument}"`, // p1: instrument name `"${instrument}"`, // p1: instrument name
time - getAudioContext().currentTime, //.toFixed(precision), // p2: starting time in arbitrary unit called beats timeOffset, // p2: starting time in arbitrary unit called beats
hap.duration + 0, // p3: duration in beats hap.duration + 0, // p3: duration in beats
// instrument specific params: // instrument specific params:
freq, //.toFixed(precision), // p4: frequency freq, //.toFixed(precision), // p4: frequency
@ -152,12 +154,14 @@ export const csoundm = register('csoundm', (instrument, pat) => {
const p2 = tidal_time - getAudioContext().currentTime; const p2 = tidal_time - getAudioContext().currentTime;
const p3 = hap.duration.valueOf() + 0; const p3 = hap.duration.valueOf() + 0;
const frequency = getFrequency(hap); const frequency = getFrequency(hap);
let { gain = 1, velocity = 0.9 } = hap.value;
velocity = gain * velocity;
// Translate frequency to MIDI key number _without_ rounding. // Translate frequency to MIDI key number _without_ rounding.
const C4 = 261.62558; const C4 = 261.62558;
let octave = Math.log(frequency / C4) / Math.log(2.0) + 8.0; let octave = Math.log(frequency / C4) / Math.log(2.0) + 8.0;
const p4 = octave * 12.0 - 36.0; const p4 = octave * 12.0 - 36.0;
// We prefer floating point precision, but over the MIDI range [0, 127]. // We prefer floating point precision, but over the MIDI range [0, 127].
const p5 = 127 * (hap.context?.velocity ?? 0.9); const p5 = 127 * velocity;
// The Strudel controls as a string. // The Strudel controls as a string.
const p6 = Object.entries({ ...hap.value, frequency }) const p6 = Object.entries({ ...hap.value, frequency })
.flat() .flat()

View File

@ -1,11 +1,11 @@
{ {
"name": "@strudel.cycles/csound", "name": "@strudel/csound",
"version": "0.9.0", "version": "1.0.1",
"description": "csound bindings for strudel", "description": "csound bindings for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module",
"publishConfig": { "publishConfig": {
"main": "dist/index.js", "main": "dist/index.mjs"
"module": "dist/index.mjs"
}, },
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
@ -33,10 +33,10 @@
"homepage": "https://github.com/tidalcycles/strudel#readme", "homepage": "https://github.com/tidalcycles/strudel#readme",
"dependencies": { "dependencies": {
"@csound/browser": "6.18.7", "@csound/browser": "6.18.7",
"@strudel.cycles/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel.cycles/webaudio": "workspace:*" "@strudel/webaudio": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"vite": "^4.3.3" "vite": "^5.0.10"
} }
} }

View File

@ -8,8 +8,8 @@ export default defineConfig({
build: { build: {
lib: { lib: {
entry: resolve(__dirname, 'index.mjs'), entry: resolve(__dirname, 'index.mjs'),
formats: ['es', 'cjs'], formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.js' }[ext]), fileName: (ext) => ({ es: 'index.mjs' })[ext],
}, },
rollupOptions: { rollupOptions: {
external: [...Object.keys(dependencies)], external: [...Object.keys(dependencies)],

View File

@ -1,16 +1,18 @@
import { Invoke } from './utils.mjs'; import { Invoke } from './utils.mjs';
import { Pattern, noteToMidi } from '@strudel.cycles/core'; import { Pattern, getEventOffsetMs, noteToMidi } from '@strudel/core';
const ON_MESSAGE = 0x90; const ON_MESSAGE = 0x90;
const OFF_MESSAGE = 0x80; const OFF_MESSAGE = 0x80;
const CC_MESSAGE = 0xb0; const CC_MESSAGE = 0xb0;
Pattern.prototype.midi = function (output) { Pattern.prototype.midi = function (output) {
return this.onTrigger((time, hap, currentTime) => { return this.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => {
const { note, nrpnn, nrpv, ccn, ccv } = hap.value; let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value;
const offset = (time - currentTime) * 1000; //magic number to get audio engine to line up, can probably be calculated somehow
const velocity = Math.floor((hap.context?.velocity ?? 0.9) * 100); // TODO: refactor velocity const latencyMs = 34;
const duration = Math.floor(hap.duration.valueOf() * 1000 - 10); const offset = getEventOffsetMs(targetTime, currentTime) + latencyMs;
velocity = Math.floor(gain * velocity * 100);
const duration = Math.floor((hap.duration.valueOf() / cps) * 1000 - 10);
const roundedOffset = Math.round(offset); const roundedOffset = Math.round(offset);
const midichan = (hap.value.midichan ?? 1) - 1; const midichan = (hap.value.midichan ?? 1) - 1;
const requestedport = output ?? 'IAC'; const requestedport = output ?? 'IAC';

View File

@ -1,8 +1,8 @@
import { parseNumeral, Pattern } from '@strudel.cycles/core'; import { parseNumeral, Pattern, getEventOffsetMs } from '@strudel/core';
import { Invoke } from './utils.mjs'; import { Invoke } from './utils.mjs';
Pattern.prototype.osc = function () { Pattern.prototype.osc = function () {
return this.onTrigger(async (time, hap, currentTime, cps = 1) => { return this.onTrigger(async (time, hap, currentTime, cps = 1, targetTime) => {
hap.ensureObjectValue(); hap.ensureObjectValue();
const cycle = hap.wholeOrPart().begin.valueOf(); const cycle = hap.wholeOrPart().begin.valueOf();
const delta = hap.duration.valueOf(); const delta = hap.duration.valueOf();
@ -13,7 +13,7 @@ Pattern.prototype.osc = function () {
const params = []; const params = [];
const timestamp = Math.round(Date.now() + (time - currentTime) * 1000); const timestamp = Math.round(Date.now() + getEventOffsetMs(targetTime, currentTime));
Object.keys(controls).forEach((key) => { Object.keys(controls).forEach((key) => {
const val = controls[key]; const val = controls[key];

View File

@ -22,8 +22,8 @@
"url": "https://github.com/tidalcycles/strudel/issues" "url": "https://github.com/tidalcycles/strudel/issues"
}, },
"dependencies": { "dependencies": {
"@strudel.cycles/core": "workspace:*", "@strudel/core": "workspace:*",
"@tauri-apps/api": "^1.4.0" "@tauri-apps/api": "^1.5.3"
}, },
"homepage": "https://github.com/tidalcycles/strudel#readme" "homepage": "https://github.com/tidalcycles/strudel#readme"
} }

9
packages/draw/README.md Normal file
View File

@ -0,0 +1,9 @@
# @strudel/canvas
Helpers for drawing with the Canvas API and Strudel
## Install
```sh
npm i @strudel/canvas --save
```

View File

@ -1,13 +1,14 @@
import { Pattern, getDrawContext, silence, register, pure } from './index.mjs'; import { Pattern, silence, register, pure, createParams } from '@strudel/core';
import controls from './controls.mjs'; // do not import from index.mjs as it breaks for some reason.. import { getDrawContext } from './draw.mjs';
const { createParams } = controls;
let clearColor = '#22222210'; let clearColor = '#22222210';
Pattern.prototype.animate = function ({ callback, sync = false, smear = 0.5 } = {}) { Pattern.prototype.animate = function ({ callback, sync = false, smear = 0.5 } = {}) {
window.frame && cancelAnimationFrame(window.frame); window.frame && cancelAnimationFrame(window.frame);
const ctx = getDrawContext(); const ctx = getDrawContext();
const { clientWidth: ww, clientHeight: wh } = ctx.canvas; let { clientWidth: ww, clientHeight: wh } = ctx.canvas;
ww *= window.devicePixelRatio;
wh *= window.devicePixelRatio;
let smearPart = smear === 0 ? '99' : Number((1 - smear) * 100).toFixed(0); let smearPart = smear === 0 ? '99' : Number((1 - smear) * 100).toFixed(0);
smearPart = smearPart.length === 1 ? `0${smearPart}` : smearPart; smearPart = smearPart.length === 1 ? `0${smearPart}` : smearPart;
clearColor = `#200010${smearPart}`; clearColor = `#200010${smearPart}`;

View File

@ -1,80 +1,88 @@
/* /*
draw.mjs - <short description TODO> draw.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/draw.mjs> Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/canvas/draw.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/>. 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, getTime, State, TimeSpan } from './index.mjs'; import { Pattern, getTime, State, TimeSpan } from '@strudel/core';
export const getDrawContext = (id = 'test-canvas') => { export const getDrawContext = (id = 'test-canvas', options) => {
let { contextType = '2d', pixelated = false, pixelRatio = window.devicePixelRatio } = options || {};
let canvas = document.querySelector('#' + id); let canvas = document.querySelector('#' + id);
if (!canvas) { if (!canvas) {
const scale = 2; // 2 = crisp on retina screens
canvas = document.createElement('canvas'); canvas = document.createElement('canvas');
canvas.id = id; canvas.id = id;
canvas.width = window.innerWidth * scale; canvas.width = window.innerWidth * pixelRatio;
canvas.height = window.innerHeight * scale; canvas.height = window.innerHeight * pixelRatio;
canvas.style = 'pointer-events:none;width:100%;height:100%;position:fixed;top:0;left:0'; canvas.style = 'pointer-events:none;width:100%;height:100%;position:fixed;top:0;left:0';
pixelated && (canvas.style.imageRendering = 'pixelated');
document.body.prepend(canvas); document.body.prepend(canvas);
let timeout; let timeout;
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
timeout && clearTimeout(timeout); timeout && clearTimeout(timeout);
timeout = setTimeout(() => { timeout = setTimeout(() => {
canvas.width = window.innerWidth * scale; canvas.width = window.innerWidth * pixelRatio;
canvas.height = window.innerHeight * scale; canvas.height = window.innerHeight * pixelRatio;
}, 200); }, 200);
}); });
} }
return canvas.getContext('2d'); return canvas.getContext(contextType);
}; };
Pattern.prototype.draw = function (callback, { from, to, onQuery } = {}) { let animationFrames = {};
function stopAnimationFrame(id) {
if (animationFrames[id] !== undefined) {
cancelAnimationFrame(animationFrames[id]);
delete animationFrames[id];
}
}
function stopAllAnimations() {
Object.keys(animationFrames).forEach((id) => stopAnimationFrame(id));
}
let memory = {};
Pattern.prototype.draw = function (fn, options) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return this; return this;
} }
if (window.strudelAnimation) { let { id = 1, lookbehind = 0, lookahead = 0 } = options;
cancelAnimationFrame(window.strudelAnimation); let __t = Math.max(getTime(), 0);
} stopAnimationFrame(id);
const ctx = getDrawContext(); lookbehind = Math.abs(lookbehind);
let cycle, // init memory, clear future haps of old pattern
events = []; memory[id] = (memory[id] || []).filter((h) => !h.isInFuture(__t));
const animate = (time) => { let newFuture = this.queryArc(__t, __t + lookahead).filter((h) => h.hasOnset());
const t = getTime(); memory[id] = memory[id].concat(newFuture);
if (from !== undefined && to !== undefined) {
const currentCycle = Math.floor(t); let last;
if (cycle !== currentCycle) { const animate = () => {
cycle = currentCycle; const _t = getTime();
const begin = currentCycle + from; const t = _t + lookahead;
const end = currentCycle + to; // filter out haps that are too far in the past
setTimeout(() => { memory[id] = memory[id].filter((h) => h.isInNearPast(lookbehind, _t));
events = this.query(new State(new TimeSpan(begin, end))) // begin where we left off in last frame, but max -0.1s (inactive tab throttles to 1fps)
.filter(Boolean) let begin = Math.max(last || t, t - 1 / 10);
.filter((event) => event.part.begin.equals(event.whole.begin)); const haps = this.queryArc(begin, t).filter((h) => h.hasOnset());
onQuery?.(events); memory[id] = memory[id].concat(haps);
}, 0); last = t; // makes sure no haps are missed
} fn(memory[id], _t, t, this);
} animationFrames[id] = requestAnimationFrame(animate);
callback(ctx, events, t, time);
window.strudelAnimation = requestAnimationFrame(animate);
}; };
requestAnimationFrame(animate); animationFrames[id] = requestAnimationFrame(animate);
return this; return this;
}; };
export const cleanupDraw = (clearScreen = true) => { export const cleanupDraw = (clearScreen = true) => {
const ctx = getDrawContext(); const ctx = getDrawContext();
clearScreen && ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.width); clearScreen && ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.width);
if (window.strudelAnimation) { stopAllAnimations();
cancelAnimationFrame(window.strudelAnimation);
}
if (window.strudelScheduler) { if (window.strudelScheduler) {
clearInterval(window.strudelScheduler); clearInterval(window.strudelScheduler);
} }
}; };
Pattern.prototype.onPaint = function (onPaint) { Pattern.prototype.onPaint = function () {
// this is evil! TODO: add pattern.context console.warn('[draw] onPaint was not overloaded. Some drawings might not work');
this.context = { onPaint };
return this; return this;
}; };
@ -134,7 +142,7 @@ export class Drawer {
this.lastFrame = phase; this.lastFrame = phase;
this.visibleHaps = (this.visibleHaps || []) this.visibleHaps = (this.visibleHaps || [])
// filter out haps that are too far in the past (think left edge of screen for pianoroll) // filter out haps that are too far in the past (think left edge of screen for pianoroll)
.filter((h) => h.whole.end >= phase - lookbehind - lookahead) .filter((h) => h.endClipped >= phase - lookbehind - lookahead)
// add new haps with onset (think right edge bars scrolling in) // add new haps with onset (think right edge bars scrolling in)
.concat(haps.filter((h) => h.hasOnset())); .concat(haps.filter((h) => h.hasOnset()));
const time = phase - lookahead; const time = phase - lookahead;
@ -175,3 +183,18 @@ export class Drawer {
} }
} }
} }
export function getComputedPropertyValue(name) {
if (typeof window === 'undefined') {
return '#fff';
}
return getComputedStyle(document.documentElement).getPropertyValue(name);
}
let theme = {};
export function getTheme() {
return theme;
}
export function setTheme(_theme) {
theme = _theme;
}

6
packages/draw/index.mjs Normal file
View File

@ -0,0 +1,6 @@
export * from './animate.mjs';
export * from './color.mjs';
export * from './draw.mjs';
export * from './pianoroll.mjs';
export * from './spiral.mjs';
export * from './pitchwheel.mjs';

View File

@ -0,0 +1,37 @@
{
"name": "@strudel/draw",
"version": "1.0.1",
"description": "Helpers for drawing with Strudel",
"main": "index.mjs",
"type": "module",
"publishConfig": {
"main": "dist/index.mjs"
},
"scripts": {
"build": "vite build",
"prepublishOnly": "npm run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/tidalcycles/strudel.git"
},
"keywords": [
"titdalcycles",
"strudel",
"pattern",
"livecoding",
"algorave"
],
"author": "Felix Roos <flix91@gmail.com>",
"license": "AGPL-3.0-or-later",
"bugs": {
"url": "https://github.com/tidalcycles/strudel/issues"
},
"homepage": "https://github.com/tidalcycles/strudel#readme",
"dependencies": {
"@strudel/core": "workspace:*"
},
"devDependencies": {
"vite": "^5.0.10"
}
}

View File

@ -1,10 +1,11 @@
/* /*
pianoroll.mjs - <short description TODO> pianoroll.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/pianoroll.mjs> Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/canvas/pianoroll.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/>. 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, noteToMidi, getDrawContext, freqToMidi, isNote } from './index.mjs'; import { Pattern, noteToMidi, freqToMidi } from '@strudel/core';
import { getTheme, getDrawContext } from './draw.mjs';
const scale = (normalized, min, max) => normalized * (max - min) + min; const scale = (normalized, min, max) => normalized * (max - min) + min;
const getValue = (e) => { const getValue = (e) => {
@ -18,7 +19,13 @@ const getValue = (e) => {
} }
note = note ?? n; note = note ?? n;
if (typeof note === 'string') { if (typeof note === 'string') {
return noteToMidi(note); try {
// TODO: n(run(32)).scale("D:minor") fails when trying to query negative time..
return noteToMidi(note);
} catch (err) {
// console.warn(`error converting note to midi: ${err}`); // this spams to crazy
return 0;
}
} }
if (typeof note === 'number') { if (typeof note === 'number') {
return note; return note;
@ -30,25 +37,24 @@ const getValue = (e) => {
}; };
Pattern.prototype.pianoroll = function (options = {}) { Pattern.prototype.pianoroll = function (options = {}) {
let { cycles = 4, playhead = 0.5, overscan = 1, hideNegative = false } = options; let { cycles = 4, playhead = 0.5, overscan = 0, hideNegative = false, ctx = getDrawContext(), id = 1 } = options;
let from = -cycles * playhead; let from = -cycles * playhead;
let to = cycles * (1 - playhead); let to = cycles * (1 - playhead);
const inFrame = (hap, t) => (!hideNegative || hap.whole.begin >= 0) && hap.isWithinTime(t + from, t + to);
this.draw( this.draw(
(ctx, haps, t) => { (haps, time) => {
const inFrame = (event) =>
(!hideNegative || event.whole.begin >= 0) && event.whole.begin <= t + to && event.endClipped >= t + from;
pianoroll({ pianoroll({
...options, ...options,
time: t, time,
ctx, ctx,
haps: haps.filter(inFrame), haps: haps.filter((hap) => inFrame(hap, time)),
}); });
}, },
{ {
from: from - overscan, lookbehind: from - overscan,
to: to + overscan, lookahead: to + overscan,
id,
}, },
); );
return this; return this;
@ -98,11 +104,8 @@ export function pianoroll({
flipTime = 0, flipTime = 0,
flipValues = 0, flipValues = 0,
hideNegative = false, hideNegative = false,
// inactive = '#C9E597', inactive = getTheme().foreground,
// inactive = '#FFCA28', active = getTheme().foreground,
inactive = '#7491D2',
active = '#FFCA28',
// background = '#2A3236',
background = 'transparent', background = 'transparent',
smear = 0, smear = 0,
playheadColor = 'white', playheadColor = 'white',
@ -121,12 +124,17 @@ export function pianoroll({
colorizeInactive = 1, colorizeInactive = 1,
fontFamily, fontFamily,
ctx, ctx,
id,
} = {}) { } = {}) {
const w = ctx.canvas.width; const w = ctx.canvas.width;
const h = ctx.canvas.height; const h = ctx.canvas.height;
let from = -cycles * playhead; let from = -cycles * playhead;
let to = cycles * (1 - playhead); let to = cycles * (1 - playhead);
if (id) {
haps = haps.filter((hap) => hap.hasTag(id));
}
if (timeframeProp) { if (timeframeProp) {
console.warn('timeframe is deprecated! use from/to instead'); console.warn('timeframe is deprecated! use from/to instead');
from = 0; from = 0;
@ -160,8 +168,13 @@ export function pianoroll({
maxMidi = max; maxMidi = max;
valueExtent = maxMidi - minMidi + 1; valueExtent = maxMidi - minMidi + 1;
} }
// foldValues = values.sort((a, b) => a - b); foldValues = values.sort((a, b) =>
foldValues = values.sort((a, b) => String(a).localeCompare(String(b))); typeof a === 'number' && typeof b === 'number'
? a - b
: typeof a === 'number'
? 1
: String(a).localeCompare(String(b)),
);
barThickness = fold ? valueAxis / foldValues.length : valueAxis / valueExtent; barThickness = fold ? valueAxis / foldValues.length : valueAxis / valueExtent;
ctx.fillStyle = background; ctx.fillStyle = background;
ctx.globalAlpha = 1; // reset! ctx.globalAlpha = 1; // reset!
@ -176,13 +189,14 @@ export function pianoroll({
if (hideInactive && !isActive) { if (hideInactive && !isActive) {
return; return;
} }
let color = event.value?.color || event.context?.color; let color = event.value?.color;
active = color || active; active = color || active;
inactive = colorizeInactive ? color || inactive : inactive; inactive = colorizeInactive ? color || inactive : inactive;
color = isActive ? active : inactive; color = isActive ? active : inactive;
ctx.fillStyle = fillCurrent ? color : 'transparent'; ctx.fillStyle = fillCurrent ? color : 'transparent';
ctx.strokeStyle = color; ctx.strokeStyle = color;
ctx.globalAlpha = event.context.velocity ?? event.value?.gain ?? 1; const { velocity = 1, gain = 1 } = event.value || {};
ctx.globalAlpha = velocity * gain;
const timeProgress = (event.whole.begin - (flipTime ? to : from)) / timeExtent; const timeProgress = (event.whole.begin - (flipTime ? to : from)) / timeExtent;
const timePx = scale(timeProgress, ...timeRange); const timePx = scale(timeProgress, ...timeRange);
let durationPx = scale(event.duration / timeExtent, 0, timeAxis); let durationPx = scale(event.duration / timeExtent, 0, timeAxis);
@ -258,8 +272,8 @@ export function getDrawOptions(drawTime, options = {}) {
export const getPunchcardPainter = export const getPunchcardPainter =
(options = {}) => (options = {}) =>
(ctx, time, haps, drawTime, paintOptions = {}) => (ctx, time, haps, drawTime) =>
pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { ...paintOptions, ...options }) }); pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, options) });
Pattern.prototype.punchcard = function (options) { Pattern.prototype.punchcard = function (options) {
return this.onPaint(getPunchcardPainter(options)); return this.onPaint(getPunchcardPainter(options));

View File

@ -0,0 +1,127 @@
import { Pattern, midiToFreq, getFrequency } from '@strudel/core';
import { getTheme, getDrawContext } from './draw.mjs';
const c = midiToFreq(36);
const circlePos = (cx, cy, radius, angle) => {
angle = angle * Math.PI * 2;
const x = Math.sin(angle) * radius + cx;
const y = Math.cos(angle) * radius + cy;
return [x, y];
};
const freq2angle = (freq, root) => {
return 0.5 - (Math.log2(freq / root) % 1);
};
export function pitchwheel({
haps,
ctx,
id,
hapcircles = 1,
circle = 0,
edo = 12,
root = c,
thickness = 3,
hapRadius = 6,
mode = 'flake',
margin = 10,
} = {}) {
const connectdots = mode === 'polygon';
const centerlines = mode === 'flake';
const w = ctx.canvas.width;
const h = ctx.canvas.height;
ctx.clearRect(0, 0, w, h);
const color = getTheme().foreground;
const size = Math.min(w, h);
const radius = size / 2 - thickness / 2 - hapRadius - margin;
const centerX = w / 2;
const centerY = h / 2;
if (id) {
haps = haps.filter((hap) => hap.hasTag(id));
}
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.globalAlpha = 1;
ctx.lineWidth = thickness;
if (circle) {
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.stroke();
}
if (edo) {
Array.from({ length: edo }, (_, i) => {
const angle = freq2angle(root * Math.pow(2, i / edo), root);
const [x, y] = circlePos(centerX, centerY, radius, angle);
ctx.beginPath();
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
ctx.fill();
});
ctx.stroke();
}
let shape = [];
ctx.lineWidth = hapRadius;
haps.forEach((hap) => {
let freq;
try {
freq = getFrequency(hap);
} catch (err) {
return;
}
const angle = freq2angle(freq, root);
const [x, y] = circlePos(centerX, centerY, radius, angle);
const hapColor = hap.value.color || color;
ctx.strokeStyle = hapColor;
ctx.fillStyle = hapColor;
const { velocity = 1, gain = 1 } = hap.value || {};
const alpha = velocity * gain;
ctx.globalAlpha = alpha;
shape.push([x, y, angle, hapColor, alpha]);
ctx.beginPath();
if (hapcircles) {
ctx.moveTo(x + hapRadius, y);
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
ctx.fill();
}
if (centerlines) {
ctx.moveTo(centerX, centerY);
ctx.lineTo(x, y);
}
ctx.stroke();
});
ctx.strokeStyle = color;
ctx.globalAlpha = 1;
if (connectdots && shape.length) {
shape = shape.sort((a, b) => a[2] - b[2]);
ctx.beginPath();
ctx.moveTo(shape[0][0], shape[0][1]);
shape.forEach(([x, y, _, color, alpha]) => {
ctx.strokeStyle = color;
ctx.globalAlpha = alpha;
ctx.lineTo(x, y);
});
ctx.lineTo(shape[0][0], shape[0][1]);
ctx.stroke();
}
return;
}
Pattern.prototype.pitchwheel = function (options = {}) {
let { ctx = getDrawContext(), id = 1 } = options;
return this.tag(id).onPaint((_, time, haps) =>
pitchwheel({
...options,
time,
ctx,
haps: haps.filter((hap) => hap.isActive(time)),
id,
}),
);
};

View File

@ -1,4 +1,5 @@
import { Pattern } from './index.mjs'; import { Pattern } from '@strudel/core';
import { getTheme } from './draw.mjs';
// polar coords -> xy // polar coords -> xy
function fromPolar(angle, radius, cx, cy) { function fromPolar(angle, radius, cx, cy) {
@ -19,7 +20,7 @@ function spiralSegment(options) {
cy = 100, cy = 100,
rotate = 0, rotate = 0,
thickness = margin / 2, thickness = margin / 2,
color = '#0000ff30', color = getTheme().foreground,
cap = 'round', cap = 'round',
stretch = 1, stretch = 1,
fromOpacity = 1, fromOpacity = 1,
@ -49,70 +50,81 @@ function spiralSegment(options) {
ctx.stroke(); ctx.stroke();
} }
Pattern.prototype.spiral = function (options = {}) { function drawSpiral(options) {
const { let {
stretch = 1, stretch = 1,
size = 80, size = 80,
thickness = size / 2, thickness = size / 2,
cap = 'butt', // round butt squar, cap = 'butt', // round butt squar,
inset = 3, // start angl, inset = 3, // start angl,
playheadColor = '#ffffff90', playheadColor = '#ffffff',
playheadLength = 0.02, playheadLength = 0.02,
playheadThickness = thickness, playheadThickness = thickness,
padding = 0, padding = 0,
steady = 1, steady = 1,
inactiveColor = '#ffffff20', activeColor = getTheme().foreground,
inactiveColor = getTheme().gutterForeground,
colorizeInactive = 0, colorizeInactive = 0,
fade = true, fade = true,
// logSpiral = true, // logSpiral = true,
ctx,
time,
haps,
drawTime,
id,
} = options; } = options;
function spiral({ ctx, time, haps, drawTime }) { if (id) {
const [w, h] = [ctx.canvas.width, ctx.canvas.height]; haps = haps.filter((hap) => hap.hasTag(id));
ctx.clearRect(0, 0, w * 2, h * 2);
const [cx, cy] = [w / 2, h / 2];
const settings = {
margin: size / stretch,
cx,
cy,
stretch,
cap,
thickness,
};
const playhead = {
...settings,
thickness: playheadThickness,
from: inset - playheadLength,
to: inset,
color: playheadColor,
};
const [min] = drawTime;
const rotate = steady * time;
haps.forEach((hap) => {
const isActive = hap.whole.begin <= time && hap.endClipped > time;
const from = hap.whole.begin - time + inset;
const to = hap.endClipped - time + inset - padding;
const { color } = hap.context;
const opacity = fade ? 1 - Math.abs((hap.whole.begin - time) / min) : 1;
spiralSegment({
ctx,
...settings,
from,
to,
rotate,
color: colorizeInactive || isActive ? color : inactiveColor,
fromOpacity: opacity,
toOpacity: opacity,
});
});
spiralSegment({
ctx,
...playhead,
rotate,
});
} }
return this.onPaint((ctx, time, haps, drawTime) => spiral({ ctx, time, haps, drawTime })); const [w, h] = [ctx.canvas.width, ctx.canvas.height];
ctx.clearRect(0, 0, w * 2, h * 2);
const [cx, cy] = [w / 2, h / 2];
const settings = {
margin: size / stretch,
cx,
cy,
stretch,
cap,
thickness,
};
const playhead = {
...settings,
thickness: playheadThickness,
from: inset - playheadLength,
to: inset,
color: playheadColor,
};
const [min] = drawTime;
const rotate = steady * time;
haps.forEach((hap) => {
const isActive = hap.whole.begin <= time && hap.endClipped > time;
const from = hap.whole.begin - time + inset;
const to = hap.endClipped - time + inset - padding;
const hapColor = hap.value?.color || activeColor;
const color = colorizeInactive || isActive ? hapColor : inactiveColor;
const opacity = fade ? 1 - Math.abs((hap.whole.begin - time) / min) : 1;
spiralSegment({
ctx,
...settings,
from,
to,
rotate,
color,
fromOpacity: opacity,
toOpacity: opacity,
});
});
spiralSegment({
ctx,
...playhead,
rotate,
});
}
Pattern.prototype.spiral = function (options = {}) {
return this.onPaint((ctx, time, haps, drawTime) => drawSpiral({ ctx, time, haps, drawTime, ...options }));
}; };

View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import { dependencies } from './package.json';
import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [],
build: {
lib: {
entry: resolve(__dirname, 'index.mjs'),
formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs' })[ext],
},
rollupOptions: {
external: [...Object.keys(dependencies)],
},
target: 'esnext',
},
});

View File

@ -1,33 +1,64 @@
# @strudel.cycles/embed # @strudel/embed
This package contains a embeddable web component for the Strudel REPL. This package contains a embeddable web component for the Strudel REPL.
## Usage ## Usage via Script Tag
Either install with `npm i @strudel.cycles/embed` or just use a cdn to import the script: Use this code in any HTML file:
```html ```html
<script src="https://unpkg.com/@strudel.cycles/embed@latest"></script> <script src="https://unpkg.com/@strudel/embed@latest"></script>
<strudel-repl> <strudel-repl>
<!-- <!--
note(`[[e5 [b4 c5] d5 [c5 b4]] setcps(1)
[a4 [a4 c5] e5 [d5 c5]] n("<0 1 2 3 4>*8").scale('G4 minor')
[b4 [~ c5] d5 e5] .s("gm_lead_6_voice")
[c5 a4 a4 ~] .clip(sine.range(.2,.8).slow(8))
[[~ d5] [~ f5] a5 [g5 f5]] .jux(rev)
[e5 [~ c5] e5 [d5 c5]] .room(2)
[b4 [b4 c5] d5 e5] .sometimes(add(note("12")))
[c5 a4 a4 ~]], .lpf(perlin.range(200,20000).slow(4))
[[e2 e3]*4] -->
[[a2 a3]*4]
[[g#2 g#3]*2 [e2 e3]*2]
[a2 a3 a2 a3 a2 a3 b1 c2]
[[d2 d3]*4]
[[c2 c3]*4]
[[b1 b2]*2 [e2 e3]*2]
[[a1 a2]*4]`).slow(16)
-->
</strudel-repl> </strudel-repl>
``` ```
Note that the Code is placed inside HTML comments to prevent the browser from treating it as HTML. This will load the strudel website in an iframe, using the code provided within the HTML comments `<!-- -->`.
The HTML comments are needed to make sure the browser won't interpret it as HTML.
Alternatively you can create a REPL from JavaScript like this:
```html
<script src="https://unpkg.com/@strudel/embed@1.0.2"></script>
<div id="strudel"></div>
<script>
let editor = document.createElement('strudel-repl');
editor.setAttribute(
'code',
`setcps(1)
n("<0 1 2 3 4>*8").scale('G4 minor')
.s("gm_lead_6_voice")
.clip(sine.range(.2,.8).slow(8))
.jux(rev)
.room(2)
.sometimes(add(note("12")))
.lpf(perlin.range(200,20000).slow(4))`,
);
document.getElementById('strudel').append(editor);
</script>
```
When you're using JSX, you could also use the `code` attribute in your markup:
```html
<script src="https://unpkg.com/@strudel/embed@1.0.2"></script>
<strudel-repl code={`
setcps(1)
n("<0 1 2 3 4>*8").scale('G4 minor')
.s("gm_lead_6_voice")
.clip(sine.range(.2,.8).slow(8))
.jux(rev)
.room(2)
.sometimes(add(note("12")))
.lpf(perlin.range(200,20000).slow(4))
`}></strudel-repl>
```

View File

@ -4,7 +4,7 @@ class Strudel extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
setTimeout(() => { setTimeout(() => {
const code = (this.innerHTML + '').replace('<!--', '').replace('-->', '').trim(); const code = this.getAttribute('code') || (this.innerHTML + '').replace('<!--', '').replace('-->', '').trim();
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
const src = `https://strudel.cc/#${encodeURIComponent(btoa(code))}`; const src = `https://strudel.cc/#${encodeURIComponent(btoa(code))}`;
// const src = `http://localhost:3000/#${encodeURIComponent(btoa(code))}`; // const src = `http://localhost:3000/#${encodeURIComponent(btoa(code))}`;

View File

@ -1,6 +1,6 @@
{ {
"name": "@strudel.cycles/embed", "name": "@strudel/embed",
"version": "0.2.0", "version": "1.0.0",
"description": "Embeddable Web Component to load a Strudel REPL into an iframe", "description": "Embeddable Web Component to load a Strudel REPL into an iframe",
"main": "embed.js", "main": "embed.js",
"type": "module", "type": "module",

View File

@ -27,7 +27,7 @@ npm i @strudel/hydra
Then add the import to your evalScope: Then add the import to your evalScope:
```js ```js
import { evalScope } from '@strudel.cycles/core'; import { evalScope } from '@strudel/core';
evalScope( evalScope(
import('@strudel/hydra') import('@strudel/hydra')

View File

@ -1,39 +1,49 @@
import { getDrawContext } from '@strudel.cycles/core'; import { getDrawContext } from '@strudel/draw';
import { controls } from '@strudel/core';
let latestOptions; let latestOptions;
let hydra;
function appendCanvas(c) {
const { canvas: testCanvas } = getDrawContext();
c.canvas.id = 'hydra-canvas';
c.canvas.style.position = 'fixed';
c.canvas.style.top = '0px';
testCanvas.after(c.canvas);
return testCanvas;
}
export async function initHydra(options = {}) { export async function initHydra(options = {}) {
// reset if options have changed since last init // reset if options have changed since last init
if (latestOptions && JSON.stringify(latestOptions) !== JSON.stringify(options)) { if (latestOptions && JSON.stringify(latestOptions) !== JSON.stringify(options)) {
document.getElementById('hydra-canvas').remove(); document.getElementById('hydra-canvas')?.remove();
} }
latestOptions = options; latestOptions = options;
//load and init hydra //load and init hydra
if (!document.getElementById('hydra-canvas')) { if (!document.getElementById('hydra-canvas')) {
console.log('reinit..');
const { const {
src = 'https://unpkg.com/hydra-synth', src = 'https://unpkg.com/hydra-synth',
feedStrudel = false, feedStrudel = false,
contextType = 'webgl',
pixelRatio = 1,
pixelated = true,
...hydraConfig ...hydraConfig
} = { detectAudio: false, ...options }; } = {
await import(src); detectAudio: false,
const hydra = new Hydra(hydraConfig); ...options,
};
const { canvas } = getDrawContext('hydra-canvas', { contextType, pixelRatio, pixelated });
hydraConfig.canvas = canvas;
await import(/* @vite-ignore */ src);
hydra = new Hydra(hydraConfig);
if (feedStrudel) { if (feedStrudel) {
const { canvas } = getDrawContext(); const { canvas } = getDrawContext();
canvas.style.display = 'none'; canvas.style.display = 'none';
hydra.synth.s0.init({ src: canvas }); hydra.synth.s0.init({ src: canvas });
} }
appendCanvas(hydra);
} }
} }
export function clearHydra() {
if (hydra) {
hydra.hush();
}
globalThis.s0?.clear();
document.getElementById('hydra-canvas')?.remove();
globalThis.speed = controls.speed;
globalThis.shape = controls.shape;
}
export const H = (p) => () => p.queryArc(getTime(), getTime())[0].value; export const H = (p) => () => p.queryArc(getTime(), getTime())[0].value;

View File

@ -1,11 +1,11 @@
{ {
"name": "@strudel/hydra", "name": "@strudel/hydra",
"version": "0.9.0", "version": "1.0.1",
"description": "Hydra integration for strudel", "description": "Hydra integration for strudel",
"main": "hydra.mjs", "main": "hydra.mjs",
"type": "module",
"publishConfig": { "publishConfig": {
"main": "dist/index.js", "main": "dist/index.mjs"
"module": "dist/index.mjs"
}, },
"scripts": { "scripts": {
"server": "node server.js", "server": "node server.js",
@ -33,11 +33,12 @@
}, },
"homepage": "https://github.com/tidalcycles/strudel#readme", "homepage": "https://github.com/tidalcycles/strudel#readme",
"dependencies": { "dependencies": {
"@strudel.cycles/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*",
"hydra-synth": "^1.3.29" "hydra-synth": "^1.3.29"
}, },
"devDependencies": { "devDependencies": {
"pkg": "^5.8.1", "pkg": "^5.8.1",
"vite": "^4.3.3" "vite": "^5.0.10"
} }
} }

View File

@ -8,8 +8,8 @@ export default defineConfig({
build: { build: {
lib: { lib: {
entry: resolve(__dirname, 'hydra.mjs'), entry: resolve(__dirname, 'hydra.mjs'),
formats: ['es', 'cjs'], formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.js' }[ext]), fileName: (ext) => ({ es: 'index.mjs' })[ext],
}, },
rollupOptions: { rollupOptions: {
external: [...Object.keys(dependencies)], external: [...Object.keys(dependencies)],

View File

@ -1,9 +1,9 @@
# @strudel.cycles/midi # @strudel/midi
This package adds midi functionality to strudel Patterns. This package adds midi functionality to strudel Patterns.
## Install ## Install
```sh ```sh
npm i @strudel.cycles/midi --save npm i @strudel/midi --save
``` ```

View File

@ -5,8 +5,8 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import * as _WebMidi from 'webmidi'; import * as _WebMidi from 'webmidi';
import { Pattern, isPattern, logger, ref } from '@strudel.cycles/core'; import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core';
import { noteToMidi } from '@strudel.cycles/core'; import { noteToMidi } from '@strudel/core';
import { Note } from 'webmidi'; import { Note } from 'webmidi';
// if you use WebMidi from outside of this package, make sure to import that instance: // if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi; export const { WebMidi } = _WebMidi;
@ -112,24 +112,24 @@ Pattern.prototype.midi = function (output) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`), logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
}); });
return this.onTrigger((time, hap, currentTime, cps) => { return this.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => {
if (!WebMidi.enabled) { if (!WebMidi.enabled) {
console.log('not enabled'); console.log('not enabled');
return; return;
} }
const device = getDevice(output, WebMidi.outputs); const device = getDevice(output, WebMidi.outputs);
hap.ensureObjectValue(); hap.ensureObjectValue();
//magic number to get audio engine to line up, can probably be calculated somehow
const offset = (time - currentTime) * 1000; const latencyMs = 34;
// passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output // passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output
const timeOffsetString = `+${offset}`; const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`;
// destructure value // destructure value
const { note, nrpnn, nrpv, ccn, ccv, midichan = 1, midicmd } = hap.value; let { note, nrpnn, nrpv, ccn, ccv, midichan = 1, midicmd, gain = 1, velocity = 0.9 } = hap.value;
const velocity = hap.context?.velocity ?? 0.9; // TODO: refactor velocity
velocity = gain * velocity;
// note off messages will often a few ms arrive late, try to prevent glitching by subtracting from the duration length // note off messages will often a few ms arrive late, try to prevent glitching by subtracting from the duration length
const duration = Math.floor(hap.duration.valueOf() * 1000 - 10); const duration = (hap.duration.valueOf() / cps) * 1000 - 10;
if (note != null) { if (note != null) {
const midiNumber = typeof note === 'number' ? note : noteToMidi(note); const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity, duration }); const midiNote = new Note(midiNumber, { attack: velocity, duration });
@ -167,9 +167,15 @@ let listeners = {};
const refs = {}; const refs = {};
export async function midin(input) { export async function midin(input) {
if (isPattern(input)) {
throw new Error(
`.midi does not accept Pattern input. Make sure to pass device name with single quotes. Example: .midi('${
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
}')`,
);
}
const initial = await enableWebMidi(); // only returns on first init const initial = await enableWebMidi(); // only returns on first init
const device = getDevice(input, WebMidi.inputs); const device = getDevice(input, WebMidi.inputs);
if (initial) { if (initial) {
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name); const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
logger( logger(

View File

@ -1,11 +1,11 @@
{ {
"name": "@strudel.cycles/midi", "name": "@strudel/midi",
"version": "0.9.0", "version": "1.0.1",
"description": "Midi API for strudel", "description": "Midi API for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module",
"publishConfig": { "publishConfig": {
"main": "dist/index.js", "main": "dist/index.mjs"
"module": "dist/index.mjs"
}, },
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
@ -29,11 +29,11 @@
}, },
"homepage": "https://github.com/tidalcycles/strudel#readme", "homepage": "https://github.com/tidalcycles/strudel#readme",
"dependencies": { "dependencies": {
"@strudel.cycles/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel.cycles/webaudio": "workspace:*", "@strudel/webaudio": "workspace:*",
"webmidi": "^3.1.5" "webmidi": "^3.1.8"
}, },
"devDependencies": { "devDependencies": {
"vite": "^4.3.3" "vite": "^5.0.10"
} }
} }

View File

@ -8,8 +8,8 @@ export default defineConfig({
build: { build: {
lib: { lib: {
entry: resolve(__dirname, 'index.mjs'), entry: resolve(__dirname, 'index.mjs'),
formats: ['es', 'cjs'], formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.js' }[ext]), fileName: (ext) => ({ es: 'index.mjs' })[ext],
}, },
rollupOptions: { rollupOptions: {
external: [...Object.keys(dependencies)], external: [...Object.keys(dependencies)],

View File

@ -1,17 +1,17 @@
# @strudel.cycles/mini # @strudel/mini
This package contains the mini notation parser and pattern generator. This package contains the mini notation parser and pattern generator.
## Install ## Install
```sh ```sh
npm i @strudel.cycles/mini --save npm i @strudel/mini --save
``` ```
## Example ## Example
```js ```js
import { mini } from '@strudel.cycles/mini'; import { mini } from '@strudel/mini';
const pattern = mini('a [b c*2]'); const pattern = mini('a [b c*2]');
@ -28,7 +28,7 @@ yields:
(7/8 -> 1/1, 7/8 -> 1/1, c) (7/8 -> 1/1, 7/8 -> 1/1, c)
``` ```
[Play with @strudel.cycles/mini codesandbox](https://codesandbox.io/s/strudel-mini-example-oe9wcu?file=/src/index.js) [Play with @strudel/mini codesandbox](https://codesandbox.io/s/strudel-mini-example-oe9wcu?file=/src/index.js)
## Mini Notation API ## Mini Notation API

Some files were not shown because too many files have changed in this diff Show More