Merge branch 'main' into add-undocumented

This commit is contained in:
Matthew Kaney
2025-03-24 16:53:55 -04:00
81 changed files with 11340 additions and 7715 deletions
-26
View File
@@ -1,26 +0,0 @@
krill-parser.js
krill.pegjs
.eslintrc.json
server.js
tidal-sniffer.js
*.jsx
tunejs.js
out/**
postcss.config.js
postcss.config.cjs
tailwind.config.js
tailwind.config.cjs
vite.config.js
/**/dist/**/*
!**/*.mjs
**/*.tsx
**/*.ts
**/*.json
**/dev-dist
**/dist
/src-tauri/target/**/*
reverbGen.mjs
hydra.mjs
jsdoc-synonyms.js
packages/hs2js/src/hs2js.mjs
samples
+2
View File
@@ -11,3 +11,5 @@ pnpm-lock.yaml
pnpm-workspace.yaml
**/dev-dist
website/.astro
!tidal-drum-machines.json
!tidal-drum-machines-alias.json
+5 -1
View File
@@ -1,6 +1,6 @@
# strudel
[![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) [![DOI](https://zenodo.org/badge/450927247.svg)](https://doi.org/10.5281/zenodo.6659278)
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.
@@ -31,6 +31,10 @@ This project is organized into many [packages](./packages), which are also avail
Read more about how to use these in your own project [here](https://strudel.cc/technical-manual/project-start).
You will need to abide by the terms of the [GNU Affero Public Licence v3](LICENSE.md). As such, Strudel code can only be shared within free/open source projects under the same license -- see the license for details.
Licensing info for the default sound banks can be found over on the [dough-samples](https://github.com/felixroos/dough-samples/blob/main/README.md) repository.
## Contributing
There are many ways to contribute to this project! See [contribution guide](./CONTRIBUTING.md).
+6 -6
View File
@@ -1,22 +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';
import { calculateSteps } from '../packages/core/index.mjs';
const tuneKeys = Object.keys(tunes);
describe('renders tunes', () => {
tuneKeys.forEach((key) => {
describe(key, () => {
calculateTactus(true);
bench(`+tactus`, async () => {
calculateSteps(true);
bench(`+steps`, async () => {
await queryCode(tunes[key], testCycles[key] || 1);
});
calculateTactus(false);
bench(`-tactus`, async () => {
calculateSteps(false);
bench(`-steps`, async () => {
await queryCode(tunes[key], testCycles[key] || 1);
});
calculateTactus(true);
calculateSteps(true);
});
});
});
+1
View File
@@ -59,6 +59,7 @@
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@tauri-apps/cli": "^2.2.7",
"@vitest/coverage-v8": "3.0.4",
"@vitest/ui": "^3.0.4",
"acorn": "^8.14.0",
"dependency-tree": "^11.0.1",
+1
View File
@@ -67,6 +67,7 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
const initialSettings = Object.keys(compartments).map((key) =>
compartments[key].of(extensions[key](parseBooleans(settings[key]))),
);
initTheme(settings.theme);
let state = EditorState.create({
doc: initialCode,
+3
View File
@@ -4,6 +4,7 @@ import blackscreen, { settings as blackscreenSettings } from './themes/blackscre
import whitescreen, { settings as whitescreenSettings } from './themes/whitescreen.mjs';
import teletext, { settings as teletextSettings } from './themes/teletext.mjs';
import algoboy, { settings as algoboySettings } from './themes/algoboy.mjs';
import CutiePi, { settings as CutiePiSettings } from './themes/CutiePi.mjs';
import terminal, { settings as terminalSettings } from './themes/terminal.mjs';
import abcdef, { settings as abcdefSettings } from './themes/abcdef.mjs';
import androidstudio, { settings as androidstudioSettings } from './themes/androidstudio.mjs';
@@ -55,6 +56,7 @@ export const themes = {
androidstudio,
duotoneDark,
githubDark,
CutiePi,
gruvboxDark,
materialDark,
nord,
@@ -98,6 +100,7 @@ export const settings = {
duotoneLight: duotoneLightSettings,
duotoneDark: duotoneDarkSettings,
eclipse: eclipseSettings,
CutiePi: CutiePiSettings,
githubLight: githubLightSettings,
githubDark: githubDarkSettings,
gruvboxDark: gruvboxDarkSettings,
+45
View File
@@ -0,0 +1,45 @@
/**
* @name Cutie Pi
* by Switch Angel
*/
import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs';
const deepPurple = '#5c019a';
const yellowPink = '#fbeffc';
const grey = '#272C35';
const pinkAccent = '#fee1ff';
const lightGrey = '#465063';
const bratGreen = '#9acd3f';
const lighterGrey = '#97a1b7';
const pink = '#f6a6fd';
export const settings = {
background: 'white',
lineBackground: 'transparent',
foreground: deepPurple,
caret: '#797977',
selection: yellowPink,
selectionMatch: '#2B323D',
gutterBackground: grey,
gutterForeground: lightGrey,
gutterBorder: 'transparent',
lineHighlight: pinkAccent,
};
export default createTheme({
theme: 'light',
settings,
styles: [
{
tag: [t.function(t.variableName), t.function(t.propertyName), t.url, t.processingInstruction],
color: deepPurple,
},
{ tag: [t.tagName, t.heading], color: settings.foreground },
{ tag: t.comment, color: lighterGrey },
{ tag: [t.variableName, t.propertyName, t.labelName], color: pink },
{ tag: [t.attributeName, t.number], color: '#d19a66' },
{ tag: t.className, color: grey },
{ tag: t.keyword, color: deepPurple },
{ tag: [t.string, t.regexp, t.special(t.propertyName)], color: bratGreen },
],
});
+1 -1
View File
@@ -4,7 +4,7 @@ import { calculateTactus, sequence, stack } from '../index.mjs';
const pat64 = sequence(...Array(64).keys());
describe('tactus', () => {
describe('steps', () => {
calculateTactus(true);
bench(
'+tactus',
+151 -12
View File
@@ -1,5 +1,5 @@
/*
controls.mjs - <short description TODO>
controls.mjs - Registers audio controls for pattern manipulation and effects.
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/controls.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/>.
*/
@@ -1513,20 +1513,11 @@ export const { scram } = registerControl('scram');
export const { binshift } = registerControl('binshift');
export const { hbrick } = registerControl('hbrick');
export const { lbrick } = registerControl('lbrick');
export const { midichan } = registerControl('midichan');
export const { control } = registerControl('control');
export const { ccn } = registerControl('ccn');
export const { ccv } = registerControl('ccv');
export const { polyTouch } = registerControl('polyTouch');
export const { midibend } = registerControl('midibend');
export const { miditouch } = registerControl('miditouch');
export const { ctlNum } = registerControl('ctlNum');
export const { frameRate } = registerControl('frameRate');
export const { frames } = registerControl('frames');
export const { hours } = registerControl('hours');
export const { midicmd } = registerControl('midicmd');
export const { minutes } = registerControl('minutes');
export const { progNum } = registerControl('progNum');
export const { seconds } = registerControl('seconds');
export const { songPtr } = registerControl('songPtr');
export const { uid } = registerControl('uid');
@@ -1619,6 +1610,151 @@ export const ar = register('ar', (t, pat) => {
return pat.set({ attack, release });
});
//MIDI
/**
* MIDI channel: Sets the MIDI channel for the event.
*
* @name midichan
* @param {number | Pattern} channel MIDI channel number (0-15)
* @example
* note("c4").midichan(1).midi()
*/
export const { midichan } = registerControl('midichan');
export const { midimap } = registerControl('midimap');
/**
* MIDI port: Sets the MIDI port for the event.
*
* @name midiport
* @param {number | Pattern} port MIDI port
* @example
* note("c a f e").midiport("<0 1 2 3>").midi()
*/
export const { midiport } = registerControl('midiport');
/**
* MIDI command: Sends a MIDI command message.
*
* @name midicmd
* @param {number | Pattern} command MIDI command
* @example
* midicmd("clock*48,<start stop>/2").midi()
*/
export const { midicmd } = registerControl('midicmd');
/**
* MIDI control: Sends a MIDI control change message.
*
* @name control
* @param {number | Pattern} MIDI control number (0-127)
* @param {number | Pattern} MIDI controller value (0-127)
*/
export const control = register('control', (args, pat) => {
if (!Array.isArray(args)) {
throw new Error('control expects an array of [ccn, ccv]');
}
const [_ccn, _ccv] = args;
return pat.ccn(_ccn).ccv(_ccv);
});
/**
* MIDI control number: Sends a MIDI control change message.
*
* @name ccn
* @param {number | Pattern} MIDI control number (0-127)
*/
export const { ccn } = registerControl('ccn');
/**
* MIDI control value: Sends a MIDI control change message.
*
* @name ccv
* @param {number | Pattern} MIDI control value (0-127)
*/
export const { ccv } = registerControl('ccv');
export const { ctlNum } = registerControl('ctlNum');
// TODO: ctlVal?
/**
* MIDI NRPN non-registered parameter number: Sends a MIDI NRPN non-registered parameter number message.
* @name nrpnn
* @param {number | Pattern} nrpnn MIDI NRPN non-registered parameter number (0-127)
* @example
* note("c4").nrpnn("1:8").nrpv("123").midichan(1).midi()
*/
export const { nrpnn } = registerControl('nrpnn');
/**
* MIDI NRPN non-registered parameter value: Sends a MIDI NRPN non-registered parameter value message.
* @name nrpv
* @param {number | Pattern} nrpv MIDI NRPN non-registered parameter value (0-127)
* @example
* note("c4").nrpnn("1:8").nrpv("123").midichan(1).midi()
*/
export const { nrpv } = registerControl('nrpv');
/**
* MIDI program number: Sends a MIDI program change message.
*
* @name progNum
* @param {number | Pattern} program MIDI program number (0-127)
* @example
* note("c4").progNum(10).midichan(1).midi()
*/
export const { progNum } = registerControl('progNum');
/**
* MIDI sysex: Sends a MIDI sysex message.
* @name sysex
* @param {number | Pattern} id Sysex ID
* @param {number | Pattern} data Sysex data
* @example
* note("c4").sysex(["0x77", "0x01:0x02:0x03:0x04"]).midichan(1).midi()
*/
export const sysex = register('sysex', (args, pat) => {
if (!Array.isArray(args)) {
throw new Error('sysex expects an array of [id, data]');
}
const [id, data] = args;
return pat.sysexid(id).sysexdata(data);
});
/**
* MIDI sysex ID: Sends a MIDI sysex identifier message.
* @name sysexid
* @param {number | Pattern} id Sysex ID
* @example
* note("c4").sysexid("0x77").sysexdata("0x01:0x02:0x03:0x04").midichan(1).midi()
*/
export const { sysexid } = registerControl('sysexid');
/**
* MIDI sysex data: Sends a MIDI sysex message.
* @name sysexdata
* @param {number | Pattern} data Sysex data
* @example
* note("c4").sysexid("0x77").sysexdata("0x01:0x02:0x03:0x04").midichan(1).midi()
*/
export const { sysexdata } = registerControl('sysexdata');
/**
* MIDI pitch bend: Sends a MIDI pitch bend message.
* @name midibend
* @param {number | Pattern} midibend MIDI pitch bend (-1 - 1)
* @example
* note("c4").midibend(sine.slow(4).range(-0.4,0.4)).midi()
*/
export const { midibend } = registerControl('midibend');
/**
* MIDI key after touch: Sends a MIDI key after touch message.
* @name miditouch
* @param {number | Pattern} miditouch MIDI key after touch (0-1)
* @example
* note("c4").miditouch(sine.slow(4).range(0,1)).midi()
*/
export const { miditouch } = registerControl('miditouch');
// TODO: what is this?
export const { polyTouch } = registerControl('polyTouch');
export const getControlName = (alias) => {
if (controlAlias.has(alias)) {
return controlAlias.get(alias);
@@ -1630,11 +1766,14 @@ export const getControlName = (alias) => {
* Sets properties in a batch.
*
* @name as
* @param {Array} mapping the control names that are set
* @param {String | Array} mapping the control names that are set
* @example
* "c:.5 a:1 f:.25 e:.8".as("note:clip")
* @example
* "{0@2 0.25 0 0.5 .3 .5}%8".as("begin").s("sax_vib").clip(1)
*/
export const as = register('as', (mapping, pat) => {
mapping = Array.isArray(mapping) ? mapping : [mapping];
return pat.fmap((v) => {
v = Array.isArray(v) ? v : [v];
v = Object.fromEntries(mapping.map((prop, i) => [getControlName(prop), v[i]]));
+369 -208
View File
File diff suppressed because it is too large Load Diff
+76 -44
View File
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import { Hap } from './hap.mjs';
import { Pattern, fastcat, pure, register, reify, silence, stack } from './pattern.mjs';
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP } from './pattern.mjs';
import Fraction from './fraction.mjs';
import { id, keyAlias, getCurrentKeyboardState } from './util.mjs';
@@ -32,30 +32,10 @@ export function steady(value) {
* @returns Pattern
*/
export const signal = (func) => {
const query = (state) => [new Hap(undefined, state.span, func(state.span.midpoint()))];
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin))];
return new Pattern(query);
};
/**
* An inverse sawtooth signal between 1 and 0.
*
* @type {Pattern}
* @example
* note("<c3 [eb3,g3] g2 [g3,bb3]>*8")
* .clip(isaw.slow(2))
* @example
* n(isaw.range(0,8).segment(8))
* .scale('C major')
*/
export const isaw = signal((t) => 1 - (t % 1));
/**
* Variant of `isaw` that ranges between 1 and -1.
*
* @type {Pattern}
*/
export const isaw2 = isaw.toBipolar();
/**
* A sawtooth signal between 0 and 1.
*
@@ -71,16 +51,37 @@ export const isaw2 = isaw.toBipolar();
export const saw = signal((t) => t % 1);
/**
* Variant of `saw` that ranges between -1 and 1.
* A sawtooth signal between -1 and 1 (like `saw`, but bipolar).
*
* @type {Pattern}
* @return {Pattern}
*/
export const saw2 = saw.toBipolar();
/**
* Variant of `sine` that ranges between -1 and 1.
* A sawtooth signal between 1 and 0 (like `saw`, but flipped).
*
* @type {Pattern}
* @return {Pattern}
* @example
* note("<c3 [eb3,g3] g2 [g3,bb3]>*8")
* .clip(isaw.slow(2))
* @example
* n(isaw.range(0,8).segment(8))
* .scale('C major')
*
*/
export const isaw = signal((t) => 1 - (t % 1));
/**
* A sawtooth signal between 1 and -1 (like `saw2`, but flipped).
*
* @return {Pattern}
*/
export const isaw2 = isaw.toBipolar();
/**
* A sine signal between -1 and 1 (like `sine`, but bipolar).
*
* @return {Pattern}
*/
export const sine2 = signal((t) => Math.sin(Math.PI * 2 * t));
@@ -107,9 +108,9 @@ export const sine = sine2.fromBipolar();
export const cosine = sine._early(Fraction(1).div(4));
/**
* Variant of `cosine` that ranges between -1 and 1.
* A cosine signal between -1 and 1 (like `cosine`, but bipolar).
*
* @type {Pattern}
* @return {Pattern}
*/
export const cosine2 = sine2._early(Fraction(1).div(4));
@@ -124,9 +125,9 @@ export const cosine2 = sine2._early(Fraction(1).div(4));
export const square = signal((t) => Math.floor((t * 2) % 2));
/**
* Variant of `square` that ranges between -1 and 1.
* A square signal between -1 and 1 (like `square`, but bipolar).
*
* @type {Pattern}
* @return {Pattern}
*/
export const square2 = square.toBipolar();
@@ -138,19 +139,36 @@ export const square2 = square.toBipolar();
* n(tri.segment(8).range(0,7)).scale("C:minor")
*
*/
export const tri = fastcat(isaw, saw);
export const tri = fastcat(saw, isaw);
/**
* Variant of `tri` that ranges between -1 and 1.
* A triangle signal between -1 and 1 (like `tri`, but bipolar).
*
* @type {Pattern}
* @return {Pattern}
*/
export const tri2 = fastcat(isaw2, saw2);
export const tri2 = fastcat(saw2, isaw2);
/**
* The current cycle count as a signal.
* An inverted triangle signal between 1 and 0 (like `tri`, but flipped).
*
* @type {Pattern}
* @return {Pattern}
* @example
* n(itri.segment(8).range(0,7)).scale("C:minor")
*
*/
export const itri = fastcat(isaw, saw);
/**
* An inverted triangle signal between -1 and 1 (like `itri`, but bipolar).
*
* @return {Pattern}
*/
export const itri2 = fastcat(isaw2, saw2);
/**
* A signal representing the cycle time.
*
* @return {Pattern}
*/
export const time = signal(id);
@@ -223,7 +241,7 @@ const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
* n(run(4)).scale("C4:pentatonic")
* // 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).round().segment(n);
/**
* Creates a pattern from a binary number.
@@ -429,19 +447,30 @@ export const chooseCycles = (...xs) => chooseInWith(rand.segment(1), xs);
export const randcat = chooseCycles;
const _wchooseWith = function (pat, ...pairs) {
// A list of patterns of values
const values = pairs.map((pair) => reify(pair[0]));
// A list of weight patterns
const weights = [];
let accum = 0;
let total = pure(0);
for (const pair of pairs) {
accum += pair[1];
weights.push(accum);
// 'add' accepts either values or patterns of values here, so no need
// to explicitly reify
total = total.add(pair[1]);
// accumulate our list of weight patterns
weights.push(total);
}
const total = accum;
// a pattern of lists of weights
const weightspat = sequenceP(weights);
// Takes a number from 0-1, returns a pattern of patterns of values
const match = function (r) {
const find = r * total;
return values[weights.findIndex((x) => x > find, weights)];
const findpat = total.mul(r);
return weightspat.fmap((weights) => (find) => values[weights.findIndex((x) => x > find, weights)]).appLeft(findpat);
};
return pat.fmap(match);
// This returns a pattern of patterns.. The innerJoin is in wchooseCycles
return pat.bind(match);
};
const wchooseWith = (...args) => _wchooseWith(...args).outerJoin();
@@ -463,6 +492,9 @@ export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
* 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()
* @example
* // The probability can itself be a pattern
* wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s()
*/
export const wchooseCycles = (...pairs) => _wchooseWith(rand.segment(1), ...pairs).innerJoin();
+6 -6
View File
@@ -30,14 +30,14 @@ describe('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 step count of the left pattern', () => {
expect(s(mini('bd cp mt').pan(mini('1 2 3 4')))._steps).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('preserves step count of the right pattern for .out', () => {
expect(s(mini('bd cp mt').set.out(pan(mini('1 2 3 4'))))._steps).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));
it('combines step count of the pattern for .mix as lcm', () => {
expect(s(mini('bd cp mt').set.mix(pan(mini('1 2 3 4'))))._steps).toEqual(Fraction(12));
});
it('finds control name by alias', () => {
expect(getControlName('lpf')).toEqual('cutoff');
+92 -91
View File
@@ -21,8 +21,7 @@ import {
cat,
sequence,
palindrome,
s_polymeter,
s_polymeterSteps,
polymeter,
polyrhythm,
silence,
fast,
@@ -51,8 +50,8 @@ import {
stackLeft,
stackRight,
stackCentre,
s_cat,
calculateTactus,
stepcat,
sometimes,
} from '../index.mjs';
import { steady } from '../signal.mjs';
@@ -609,19 +608,12 @@ describe('Pattern', () => {
);
});
});
describe('s_polymeter()', () => {
describe('polymeter()', () => {
it('Can layer up cycles, stepwise, with lists', () => {
expect(s_polymeterSteps(3, ['d', 'e']).firstCycle()).toStrictEqual(
fastcat(pure('d'), pure('e'), pure('d')).firstCycle(),
);
expect(s_polymeter(['a', 'b', 'c'], ['d', 'e']).fast(2).firstCycle()).toStrictEqual(
expect(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(),
);
});
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()', () => {
@@ -736,21 +728,15 @@ describe('Pattern', () => {
describe('signal()', () => {
it('Can make saw/saw2', () => {
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(1 / 8, 3 / 8, 5 / 8, 7 / 8).firstCycle(),
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
);
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(-3 / 4, -1 / 4, 1 / 4, 3 / 4).firstCycle(),
);
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
});
it('Can make isaw/isaw2', () => {
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(7 / 8, 5 / 8, 3 / 8, 1 / 8).firstCycle(),
);
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(3 / 4, 1 / 4, -1 / 4, -3 / 4).firstCycle(),
);
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
});
});
describe('_setContext()', () => {
@@ -888,7 +874,7 @@ describe('Pattern', () => {
.squeezeJoin()
.queryArc(3, 4)
.map((x) => x.value),
).toStrictEqual([Fraction(3.5)]);
).toStrictEqual([Fraction(3)]);
});
});
describe('ply', () => {
@@ -1140,130 +1126,145 @@ describe('Pattern', () => {
);
});
});
describe('tactus', () => {
describe('_steps', () => {
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(
expect(sequence(0, 1, 2, 3).linger(4)._steps).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).iter(4)._steps).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).fast(4)._steps).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).hurry(4)._steps).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).rev()._steps).toStrictEqual(Fraction(4));
expect(sequence(1).segment(10)._steps).toStrictEqual(Fraction(10));
expect(sequence(1, 0, 1).invert()._steps).toStrictEqual(Fraction(3));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).chop(4)._steps).toStrictEqual(Fraction(8));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).striate(4)._steps).toStrictEqual(Fraction(8));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).slice(4, sequence(0, 1, 2, 3))._steps).toStrictEqual(
Fraction(4),
);
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).splice(4, sequence(0, 1, 2, 3)).tactus).toStrictEqual(
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).splice(4, sequence(0, 1, 2, 3))._steps).toStrictEqual(
Fraction(4),
);
expect(sequence({ n: 0 }, { n: 1 }, { n: 2 }).chop(4).tactus).toStrictEqual(Fraction(12));
expect(sequence({ n: 0 }, { n: 1 }, { n: 2 }).chop(4)._steps).toStrictEqual(Fraction(12));
expect(
pure((x) => x + 1)
.setTactus(3)
.appBoth(pure(1).setTactus(2)).tactus,
.setSteps(3)
.appBoth(pure(1).setSteps(2))._steps,
).toStrictEqual(Fraction(6));
expect(
pure((x) => x + 1)
.setTactus(undefined)
.appBoth(pure(1).setTactus(2)).tactus,
.setSteps(undefined)
.appBoth(pure(1).setSteps(2))._steps,
).toStrictEqual(Fraction(2));
expect(
pure((x) => x + 1)
.setTactus(3)
.appBoth(pure(1).setTactus(undefined)).tactus,
.setSteps(3)
.appBoth(pure(1).setSteps(undefined))._steps,
).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));
expect(stack(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(6));
expect(stack(fastcat(0, 1, 2), fastcat(3, 4).setSteps(undefined))._steps).toStrictEqual(Fraction(3));
expect(stackLeft(fastcat(0, 1, 2, 3), fastcat(3, 4))._steps).toStrictEqual(Fraction(4));
expect(stackRight(fastcat(0, 1, 2), fastcat(3, 4))._steps).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);
expect(stackCentre(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
expect(fastcat(0, 1).ply(3)._steps).toStrictEqual(Fraction(6));
expect(fastcat(0, 1).setSteps(undefined).ply(3)._steps).toStrictEqual(undefined);
expect(fastcat(0, 1).fast(3)._steps).toStrictEqual(Fraction(2));
expect(fastcat(0, 1).setSteps(undefined).fast(3)._steps).toStrictEqual(undefined);
});
});
describe('s_cat', () => {
describe('stepcat', () => {
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)));
expect(sameFirst(stepcat(fastcat(0, 1, 2, 3), fastcat(4, 5)), fastcat(0, 1, 2, 3, 4, 5)));
expect(sameFirst(stepcat(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)));
it('calculates undefined steps as the average', () => {
expect(sameFirst(stepcat(pure(1), pure(2), pure(3).setSteps(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)));
describe('shrink', () => {
it('can shrink', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).shrink(1), sequence(0, 1, 2, 3, 4, 1, 2, 3, 4, 2, 3, 4, 3, 4, 4)));
});
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)));
it('can shrink backwards', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).shrink(-1), sequence(0, 1, 2, 3, 4, 0, 1, 2, 3, 0, 1, 2, 0, 1, 0)));
});
});
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)));
describe('grow', () => {
it('can grow', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).grow(1), sequence(0, 0, 1, 0, 1, 2, 0, 1, 2, 3, 0, 1, 2, 3, 4)));
});
it('can sub to the left', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).s_sub(2), sequence(0, 1, 2)));
it('can grow backwards', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).grow(-1), sequence(4, 3, 4, 2, 3, 4, 1, 2, 3, 4, 0, 1, 2, 3, 4)));
});
it('can add from the right', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).s_add(-2), sequence(3, 4)));
});
describe('take and drop', () => {
it('can take from the left', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).take(2), sequence(0, 1)));
});
it('can sub to the right', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).s_sub(-2), sequence(2, 3, 4)));
it('can drop from the left', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).drop(2), sequence(2, 3, 4)));
});
it('can subtract nothing', () => {
expect(sameFirst(pure('a').s_sub(0), pure('a')));
it('can take from the right', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).take(-2), sequence(3, 4)));
});
it('can subtract nothing, repeatedly', () => {
expect(sameFirst(pure('a').s_sub(0, 0), fastcat('a', 'a')));
it('can drop from the right', () => {
expect(sameFirst(sequence(0, 1, 2, 3, 4).drop(-2), sequence(0, 1, 2)));
});
it('can drop nothing', () => {
expect(sameFirst(pure('a').drop(0), pure('a')));
});
it('can drop nothing, repeatedly', () => {
expect(sameFirst(pure('a').drop(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'))));
expect(sameFirst(pure('a').drop(...Array(i).fill(0)), fastcat(...Array(i).fill('a'))));
}
});
});
describe('s_expand', () => {
describe('expand', () => {
it('can expand four things in half', () => {
expect(
sameFirst(
sequence(0, 1, 2, 3).s_expand(1, 0.5),
s_cat(sequence(0, 1, 2, 3), sequence(0, 1, 2, 3).s_expand(0.5)),
),
sameFirst(sequence(0, 1, 2, 3).expand(1, 0.5), stepcat(sequence(0, 1, 2, 3), sequence(0, 1, 2, 3).expand(0.5))),
);
});
it('can expand five things in half', () => {
expect(
sameFirst(
sequence(0, 1, 2, 3, 4).s_expand(1, 0.5),
s_cat(sequence(0, 1, 2, 3, 4), sequence(0, 1, 2, 3, 4).s_expand(0.5)),
sequence(0, 1, 2, 3, 4).expand(1, 0.5),
stepcat(sequence(0, 1, 2, 3, 4), sequence(0, 1, 2, 3, 4).expand(0.5)),
),
);
});
});
describe('stepJoin', () => {
it('can join a pattern with a tactus of 2', () => {
it('can join a pattern with steps of 2', () => {
expect(
sameFirst(
sequence(pure(pure('a')), pure(pure('b').setTactus(2))).stepJoin(),
s_cat(pure('a'), pure('b').setTactus(2)),
sequence(pure(pure('a')), pure(pure('b').setSteps(2))).stepJoin(),
stepcat(pure('a'), pure('b').setSteps(2)),
),
);
});
it('can join a pattern with a tactus of 0.5', () => {
it('can join a pattern with steps of 0.5', () => {
expect(
sameFirst(
sequence(pure(pure('a')), pure(pure('b').setTactus(0.5))).stepJoin(),
s_cat(pure('a'), pure('b').setTactus(0.5)),
sequence(pure(pure('a')), pure(pure('b').setSteps(0.5))).stepJoin(),
stepcat(pure('a'), pure('b').setSteps(0.5)),
),
);
});
});
describe('loopAt', () => {
it('maintains tactus', () => {
expect(s('bev').chop(8).loopAt(2).tactus).toStrictEqual(Fraction(4));
it('maintains steps', () => {
expect(s('bev').chop(8).loopAt(2)._steps).toStrictEqual(Fraction(4));
});
});
describe('bite', () => {
it('works with uneven patterns', () => {
sameFirst(
fastcat(slowcat('a', 'b', 'c', 'd', 'e'), slowcat(1, 2, 3, 4, 5))
.bite(2, stepcat(pure(0), pure(1).expand(2)))
.fast(5),
stepcat(slowcat('a', 'b', 'c', 'd', 'e'), slowcat(1, 2, 3, 4, 5).expand(2)).fast(5),
);
});
});
});
+93
View File
@@ -0,0 +1,93 @@
# @strudel/gamepad
This package adds gamepad input functionality to strudel Patterns.
## Install
```sh
npm i @strudel/gamepad --save
```
## Usage
```javascript
import { gamepad } from '@strudel/gamepad';
// Initialize gamepad (optional index parameter, defaults to 0)
const pad = gamepad(0);
// Use gamepad inputs in patterns
const pattern = sequence([
// Button inputs
pad.a, // A button value (0-1)
pad.tglA, // A button toggle (0 or 1)
// Analog stick inputs
pad.x1, // Left stick X (0-1)
pad.x1_2, // Left stick X (-1 to 1)
]);
```
## Available Controls
### Buttons
- Face Buttons
- `a`, `b`, `x`, `y` (or uppercase `A`, `B`, `X`, `Y`)
- Toggle versions: `tglA`, `tglB`, `tglX`, `tglY`
- Shoulder Buttons
- `lb`, `rb`, `lt`, `rt` (or uppercase `LB`, `RB`, `LT`, `RT`)
- Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT`
- D-Pad
- `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase)
- Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`)
### Analog Sticks
- Left Stick
- `x1`, `y1` (0 to 1 range)
- `x1_2`, `y1_2` (-1 to 1 range)
- Right Stick
- `x2`, `y2` (0 to 1 range)
- `x2_2`, `y2_2` (-1 to 1 range)
## Examples
```javascript
// Use button values to control amplitude
$: sequence([
s("bd").gain(pad.X), // X button controls gain
s("[hh oh]").gain(pad.tglY), // Y button toggles gain
]);
// Use analog stick for continuous control
$: note("c4*4".add(pad.y1_2.range(-24,24))) // Left stick Y controls pitch shift
.pan(pad.x1_2); // Left stick X controls panning
// Use toggle buttons to switch patterns on/off
// Define button sequences
const HADOKEN = [
'd', // Down
'r', // Right
'a', // A
];
const KONAMI = 'uuddlrlrba' //Konami Code ↑↑↓↓←→←→BA
// Add these lines to enable buttons(but why?)
$:pad.D.segment(16).gain(0)
$:pad.R.segment(16).gain(0)
$:pad.A.segment(16).gain(0)
// Check button sequence (returns 1 when detected, 0 when not within last 1 second)
$: sound("hadoken").gain(pad.checkSequence(HADOKEN))
```
## Multiple Gamepads
You can connect multiple gamepads by specifying the gamepad index:
```javascript
const pad1 = gamepad(0); // First gamepad
const pad2 = gamepad(1); // Second gamepad
```
+117
View File
@@ -0,0 +1,117 @@
import { MiniRepl } from '../../../website/src/docs/MiniRepl';
# Gamepad
The Gamepad module allows you to integrate gamepad input functionality into your musical patterns. This can be particularly useful for live performances or interactive installations where you want to manipulate sounds using a game controller.
## Getting Started
Initialize a gamepad by calling the gamepad() function with an optional index parameter.
<MiniRepl
client:idle
tune={`// Initialize gamepad (optional index parameter, defaults to 0)
const gp = gamepad(0)
note("c a f e").mask(gp.a)`}
/>
## Available Controls
The gamepad module provides access to buttons and analog sticks as normalized signals (0-1) that can modulate your patterns.
### Buttons
| Type | Controls |
| ---------------- | ---------------------------------------------------------------------------------------------- |
| Face Buttons | `a`, `b`, `x`, `y` (or uppercase `A`, `B`, `X`, `Y`) |
| | Toggle versions: `tglA`, `tglB`, `tglX`, `tglY` |
| Shoulder Buttons | `lb`, `rb`, `lt`, `rt` (or uppercase `LB`, `RB`, `LT`, `RT`) |
| | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` |
| D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) |
| | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) |
### Analog Sticks
| Stick | Controls |
| ----------- | ------------------------------ |
| Left Stick | `x1`, `y1` (0 to 1 range) |
| | `x1_2`, `y1_2` (-1 to 1 range) |
| Right Stick | `x2`, `y2` (0 to 1 range) |
| | `x2_2`, `y2_2` (-1 to 1 range) |
### Button Sequence
| Stick | Controls |
| --------------- | --------------------------------------- |
| Button Sequence | `btnSequence()`, `btnSeq()`, `btnseq()` |
## Using Gamepad Inputs
Once initialized, you can use various gamepad inputs in your patterns. Here are some examples:
### Button Inputs
You can use button inputs to control different aspects of your music, such as gain or triggering events.
<MiniRepl
client:idle
tune={`const gp = gamepad(0)
// Use button values to control amplitude
$: stack(
s("[[hh hh] oh hh oh]/2").mask(gp.tglX).bank("RolandTR909"), // X btn for HH
s("cr*1").mask(gp.Y).bank("RolandTR909"), // LB btn for CR
s("bd").mask(gp.tglA).bank("RolandTR909"), // A btn for BD
s("[ht - - mt - - lt - ]/2").mask(gp.tglB).bank("RolandTR909"), // B btn for Toms
s("sd*4").mask(gp.RB).bank("RolandTR909"), // RB btn for SD
).cpm(120)
`}
/>
### Analog Stick Inputs
Analog sticks can be used for continuous control, such as pitch shifting or panning.
<MiniRepl
client:idle
tune={`const gp = gamepad(0)
// Use analog stick for continuous control
$: note("c4 d3 a3 e3").sound("sawtooth")
.lpf(gp.x1.range(100,4000))
.lpq(gp.y1.range(5,30))
.decay(gp.y2.range(0.1,2))
.lpenv(gp.x2.range(-5,5))
.cpm(120)
`}
/>
### Button Sequences
You can define button sequences to trigger specific actions, like playing a sound when a sequence is detected.
<MiniRepl client:idle tune={`const gp = gamepad(0)
// Define button sequences
const HADOUKEN = [
'd', // Down
'r', // Right
'a', // A
]
const KONAMI = 'uuddlrlrba' //Konami Code ↑↑↓↓←→←→BA
// Check butto-n sequence (returns 1 while detected, 0 when not within last 1 second)
$: s("free_hadouken -").slow(2)
.mask(gp.btnSequence(HADOUKEN)).room(1).cpm(120)
// hadouken.wav by Syna-Max
//https://freesound.org/people/Syna-Max/sounds/67674/
samples({free_hadouken: 'https://cdn.freesound.org/previews/67/67674_111920-lq.mp3'})
`} />
## Multiple Gamepads
Strudel supports multiple gamepads. You can specify the gamepad index to connect to different devices.
<MiniRepl
client:idle
tune={`const pad1 = gamepad(0); // First gamepad
const pad2 = gamepad(1); // Second gamepad`}
/>
+246
View File
@@ -0,0 +1,246 @@
// @strudel/gamepad/index.mjs
import { signal } from '@strudel/core';
// Button mapping for Logitech Dual Action (STANDARD GAMEPAD Vendor: 046d Product: c216)
export const buttonMap = {
a: 0,
b: 1,
x: 2,
y: 3,
lb: 4,
rb: 5,
lt: 6,
rt: 7,
back: 8,
start: 9,
u: 12,
up: 12,
d: 13,
down: 13,
l: 14,
left: 14,
r: 15,
right: 15,
};
class ButtonSequenceDetector {
constructor(timeWindow = 1000) {
this.sequence = [];
this.timeWindow = timeWindow;
this.lastInputTime = 0;
this.buttonStates = Array(16).fill(0); // Track previous state of each button
// Button mapping for character inputs
}
addInput(buttonIndex, buttonValue) {
const currentTime = Date.now();
// Only add input on button press (rising edge)
if (buttonValue === 1 && this.buttonStates[buttonIndex] === 0) {
// Clear sequence if too much time has passed
if (currentTime - this.lastInputTime > this.timeWindow) {
this.sequence = [];
}
// Store the button name instead of index
const buttonName = Object.keys(buttonMap).find((key) => buttonMap[key] === buttonIndex) || buttonIndex.toString();
this.sequence.push({
input: buttonName,
timestamp: currentTime,
});
this.lastInputTime = currentTime;
//console.log(this.sequence);
// Keep only inputs within the time window
this.sequence = this.sequence.filter((entry) => currentTime - entry.timestamp <= this.timeWindow);
}
// Update button state
this.buttonStates[buttonIndex] = buttonValue;
}
checkSequence(targetSequence) {
if (!Array.isArray(targetSequence) && typeof targetSequence !== 'string') {
console.error('ButtonSequenceDetector: targetSequence must be an array or string');
return 0;
}
if (this.sequence.length < targetSequence.length) return 0;
// Convert string input to array if needed
const sequence =
typeof targetSequence === 'string'
? targetSequence.toLowerCase().split('')
: targetSequence.map((s) => s.toString().toLowerCase());
//console.log(this.sequence);
// Get the last n inputs where n is the target sequence length
const lastInputs = this.sequence.slice(-targetSequence.length).map((entry) => entry.input);
// Compare sequences
return lastInputs.every((input, index) => {
const target = sequence[index];
// Check if either the input matches directly or they refer to the same button in the map
return (
input === target ||
buttonMap[input] === buttonMap[target] ||
// Also check if the numerical index matches
buttonMap[input] === parseInt(target)
);
})
? 1
: 0;
}
}
class GamepadHandler {
constructor(index = 0) {
// Add index parameter
this._gamepads = {};
this._activeGamepad = index; // Use provided index
this._axes = [0, 0, 0, 0];
this._buttons = Array(16).fill(0);
this.setupEventListeners();
}
setupEventListeners() {
window.addEventListener('gamepadconnected', (e) => {
this._gamepads[e.gamepad.index] = e.gamepad;
if (!this._activeGamepad) {
this._activeGamepad = e.gamepad.index;
}
});
window.addEventListener('gamepaddisconnected', (e) => {
delete this._gamepads[e.gamepad.index];
if (this._activeGamepad === e.gamepad.index) {
this._activeGamepad = Object.keys(this._gamepads)[0] || null;
}
});
}
poll() {
if (this._activeGamepad !== null) {
const gamepad = navigator.getGamepads()[this._activeGamepad];
if (gamepad) {
// Update axes (normalized to 0-1 range)
this._axes = gamepad.axes.map((axis) => (axis + 1) / 2);
// Update buttons
this._buttons = gamepad.buttons.map((button) => button.value);
}
}
}
getAxes() {
return this._axes;
}
getButtons() {
return this._buttons;
}
}
// Module-level state store for toggle states
const gamepadStates = new Map();
export const gamepad = (index = 0) => {
const handler = new GamepadHandler(index);
const sequenceDetector = new ButtonSequenceDetector(2000);
// Base signal that polls gamepad state and handles sequence detection
const baseSignal = signal((t) => {
handler.poll();
const axes = handler.getAxes();
const buttons = handler.getButtons();
// Add all button inputs to sequence detector
buttons.forEach((value, i) => {
sequenceDetector.addInput(i, value);
});
return { axes, buttons, t };
});
// Create axes patterns
const axes = {
x1: baseSignal.fmap((state) => state.axes[0]),
y1: baseSignal.fmap((state) => state.axes[1]),
x2: baseSignal.fmap((state) => state.axes[2]),
y2: baseSignal.fmap((state) => state.axes[3]),
};
// Add bipolar versions
axes.x1_2 = axes.x1.toBipolar();
axes.y1_2 = axes.y1.toBipolar();
axes.x2_2 = axes.x2.toBipolar();
axes.y2_2 = axes.y2.toBipolar();
// Create button patterns
const buttons = Array(16)
.fill(null)
.map((_, i) => {
// Create unique key for this gamepad+button combination
const stateKey = `gamepad${index}_btn${i}`;
// Initialize toggle state if it doesn't exist
if (!gamepadStates.has(stateKey)) {
gamepadStates.set(stateKey, {
lastButtonState: 0,
toggleState: 0,
});
}
// Direct button value pattern (no longer needs to call addInput)
const btn = baseSignal.fmap((state) => state.buttons[i]);
// Button toggle pattern with persistent state
const toggle = baseSignal.fmap((state) => {
const currentState = state.buttons[i];
const buttonState = gamepadStates.get(stateKey);
if (currentState === 1 && buttonState.lastButtonState === 0) {
// Toggle the state on rising edge
buttonState.toggleState = buttonState.toggleState === 0 ? 1 : 0;
}
buttonState.lastButtonState = currentState;
return buttonState.toggleState;
});
return { value: btn, toggle };
});
// Create sequence checker pattern
const btnSequence = (sequence) => {
return baseSignal.fmap(() => sequenceDetector.checkSequence(sequence));
};
const checkSequence = btnSequence;
const btnSeq = btnSequence;
const btnseq = btnSeq;
// Return an object with all controls
return {
...axes,
buttons,
...Object.fromEntries(
Object.entries(buttonMap).flatMap(([key, index]) => [
[key.toLowerCase(), buttons[index].value],
[key.toUpperCase(), buttons[index].value],
[`tgl${key.toLowerCase()}`, buttons[index].toggle],
[`tgl${key.toUpperCase()}`, buttons[index].toggle],
]),
),
checkSequence,
btnSequence,
btnSeq,
btnseq,
raw: baseSignal,
};
};
// Optional: Export for debugging or state management
export const getGamepadStates = () => Object.fromEntries(gamepadStates);
export const clearGamepadStates = () => gamepadStates.clear();
+3
View File
@@ -0,0 +1,3 @@
import './gamepad.mjs';
export * from './gamepad.mjs';
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@strudel/gamepad",
"version": "1.1.0",
"description": "Gamepad Inputs for 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": "Yuta Nakayama <nkymut@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": "^6.0.11"
}
}
+19
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',
},
});
+184
View File
@@ -7,3 +7,187 @@ This package adds midi functionality to strudel Patterns.
```sh
npm i @strudel/midi --save
```
## Available Controls
The following MIDI controls are available:
OUTPUT:
- `midi` - opens a midi output device.
- `note` - Sends MIDI note messages. Can accept note names (e.g. "c4") or MIDI note numbers (0-127)
- `midichan` - Sets the MIDI channel (1-16, defaults to 1)
- `velocity` - Sets note velocity (0-1, defaults to 0.9)
- `gain` - Modifies velocity by multiplying with it (0-1, defaults to 1)
- `control` - Sets MIDI control change messages
- `ccn` - Sets MIDI CC controller number (0-127)
- `ccv` - Sets MIDI CC value (0-1)
- `progNum` - Sends MIDI program change messages (0-127)
- `sysex` - Sends MIDI System Exclusive messages (id: number 0-127 or array of bytes 0-127, data: array of bytes 0-127)
- `sysexid` - Sets MIDI System Exclusive ID (number 0-127 or array of bytes 0-127)
- `sysexdata` - Sets MIDI System Exclusive data (array of bytes 0-127)
- `midibend` - Sets MIDI pitch bend (-1 - 1)
- `miditouch` - Sets MIDI key after touch (0-1)
- `midicmd` - Sends MIDI system real-time messages to control timing and transport on MIDI devices.
- `nrpnn` - Sets MIDI NRPN non-registered parameter number (array of bytes 0-127)
- `nrpv` - Sets MIDI NRPN non-registered parameter value (0-127)
INPUT:
- `midin` - Opens a MIDI input port to receive MIDI control change messages.
Additional controls can be mapped using the mapping object passed to `.midi()`:
## Examples
### midi(outputName?, options?)
Either connect a midi device or use the IAC Driver (Mac) or Midi Through Port (Linux) for internal midi messages.
If no outputName is given, it uses the first midi output it finds.
```javascript
$: chord("<C^7 A7 Dm7 G7>").voicing().midi('IAC Driver')
```
In the console, you will see a log of the available MIDI devices as soon as you run the code, e.g. `Midi connected! Using "Midi Through Port-0".`
### Options
The `.midi()` function accepts an options object with the following properties:
```javascript
$: note("c a f e").midi('IAC Driver', { isController: true, midimap: 'default'})
```
<details>
<summary>Available Options</summary>
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| isController | boolean | false | When true, disables sending note messages. Useful for MIDI controllers |
| latencyMs | number | 34 | Latency in milliseconds to align MIDI with audio engine |
| noteOffsetMs | number | 10 | Offset in milliseconds for note-off messages to prevent glitching |
| midichannel | number | 1 | Default MIDI channel (1-16) |
| velocity | number | 0.9 | Default note velocity (0-1) |
| gain | number | 1 | Default gain multiplier for velocity (0-1) |
| midimap | string | 'default' | Name of MIDI mapping to use for control changes |
| midiport | string/number | - | MIDI device name or index |
</details>
### midiport(outputName)
Selects the MIDI output device to use, pattern can be used to switch between devices.
```javascript
$: midiport('IAC Driver')
$: note("c a f e").midiport("<0 1 2 3>").midi()
```
### midichan(number)
Selects the MIDI channel to use. If not used, `.midi` will use channel 1 by default.
### control, ccn && ccv
`control` sends MIDI control change messages to your MIDI device.
- `ccn` sets the cc number. Depends on your synths midi mapping
- `ccv` sets the cc value. normalized from 0 to 1.
```javascript
$: note("c a f e").control([74, sine.slow(4)]).midi()
$: note("c a f e").ccn(74).ccv(sine.slow(4)).midi()
```
In the above snippet, `ccn` is set to 74, which is the filter cutoff for many synths. `ccv` is controlled by a saw pattern.
Having everything in one pattern, the `ccv` pattern will be aligned to the note pattern, because the structure comes from the left by default.
But you can also control cc messages separately like this:
```javascript
$: note("c a f e").midi()
$: ccv(sine.segment(16).slow(4)).ccn(74).midi()
```
### progNum (Program Change)
`progNum` control sends MIDI program change messages to switch between different presets/patches on your MIDI device.
Program change values should be numbers between 0 and 127.
```javascript
// Play notes while changing programs
note("c3 e3 g3").progNum("<0 1 2>").midi()
```
Program change messages are useful for switching between different instrument sounds or presets during a performance.
The exact sound that each program number maps to depends on your MIDI device's configuration.
## sysex, sysexid && sysexdata (System Exclusive Message)
`sysex`, `sysexid` and `sysexdata` control sends MIDI System Exclusive (SysEx) messages to your MIDI device.
sysEx messages are device-specific commands that allow deeper control over synthesizer parameters.
The value should be an array of numbers between 0-255 representing the SysEx data bytes.
```javascript
// Send a simple SysEx message
let id = 0x43; //Yamaha
//let id = "0x00:0x20:0x32"; //Behringer ID can be an array of numbers
let data = "0x79:0x09:0x11:0x0A:0x00:0x00"; // Set NSX-39 voice to say "Aa"
$: note("c d e f e d c").sysex(id, data).midi();
$: note("c d e f e d c").sysexid(id).sysexdata(data).midi();
```
The exact format of SysEx messages depends on your MIDI device's specification.
Consult your device's MIDI implementation guide for details on supported SysEx messages.
### midibend && miditouch
`midibend` sets MIDI pitch bend (-1 - 1)
`miditouch` sets MIDI key after touch (0-1)
```javascript
$: note("c d e f e d c").midibend(sine.slow(4).range(-0.4,0.4)).midi();
$: note("c d e f e d c").miditouch(sine.slow(4).range(0,1)).midi();
```
### midicmd
`midicmd` sends MIDI system real-time messages to control timing and transport on MIDI devices.
It supports the following commands:
- `clock`/`midiClock` - Sends MIDI timing clock messages
- `start` - Sends MIDI start message
- `stop` - Sends MIDI stop message
- `continue` - Sends MIDI continue message
```javascript
// You can control the clock with a pattern and ensure it starts in sync when the repl begins.
// Note: It might act unexpectedly if MIDI isn't set up initially.
stack(
midicmd("clock*48,<start stop>/2").midi('IAC Driver')
)
```
`midicmd` also supports sending control change, program change and sysex messages.
- `cc` - sends MIDI control change messages.
- `progNum` - sends MIDI program change messages.
- `sysex` - sends MIDI system exclusive messages.
```javascript
stack(
// "cc:ccn:ccv"
midicmd("cc:74:1").midi('IAC Driver'),
// "progNum:progNum"
midicmd("progNum:1").midi('IAC Driver'),
// "sysex:[sysexid]:[sysexdata]"
midicmd("sysex:[0x43]:[0x79:0x09:0x11:0x0A:0x00:0x00]").midi('IAC Driver')
)
```
+338 -33
View File
@@ -6,8 +6,9 @@ This program is free software: you can redistribute it and/or modify it under th
import * as _WebMidi from 'webmidi';
import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core';
import { noteToMidi } from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi';
// if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi;
@@ -43,13 +44,16 @@ export function enableWebMidi(options = {}) {
resolve(WebMidi);
return;
}
WebMidi.enable((err) => {
if (err) {
reject(err);
}
onReady?.(WebMidi);
resolve(WebMidi);
});
WebMidi.enable(
(err) => {
if (err) {
reject(err);
}
onReady?.(WebMidi);
resolve(WebMidi);
},
{ sysex: true },
);
});
}
@@ -89,18 +93,235 @@ if (typeof window !== 'undefined') {
});
}
Pattern.prototype.midi = function (output) {
if (isPattern(output)) {
// registry for midi mappings, converting control names to cc messages
export const midicontrolMap = new Map();
// takes midimap and converts each control key to the main control name
function unifyMapping(mapping) {
return Object.fromEntries(
Object.entries(mapping).map(([key, mapping]) => {
if (typeof mapping === 'number') {
mapping = { ccn: mapping };
}
return [getControlName(key), mapping];
}),
);
}
function githubPath(base, subpath = '') {
if (!base.startsWith('github:')) {
throw new Error('expected "github:" at the start of pseudoUrl');
}
let [_, path] = base.split('github:');
path = path.endsWith('/') ? path.slice(0, -1) : path;
if (path.split('/').length === 2) {
// assume main as default branch if none set
path += '/main';
}
return `https://raw.githubusercontent.com/${path}/${subpath}`;
}
/**
* configures the default midimap, which is used when no "midimap" port is set
* @example
* defaultmidimap({ lpf: 74 })
* $: note("c a f e").midi();
* $: lpf(sine.slow(4).segment(16)).midi();
*/
export function defaultmidimap(mapping) {
midicontrolMap.set('default', unifyMapping(mapping));
}
let loadCache = {};
/**
* Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
* @example
* midimaps({ mymap: { lpf: 74 } })
* $: note("c a f e")
* .lpf(sine.slow(4))
* .midimap('mymap')
* .midi()
* @example
* midimaps({ mymap: {
* lpf: { ccn: 74, min: 0, max: 20000, exp: 0.5 }
* }})
* $: note("c a f e")
* .lpf(sine.slow(2).range(400,2000))
* .midimap('mymap')
* .midi()
*/
export async function midimaps(map) {
if (typeof map === 'string') {
if (map.startsWith('github:')) {
map = githubPath(map, 'midimap.json');
}
if (!loadCache[map]) {
loadCache[map] = fetch(map).then((res) => res.json());
}
map = await loadCache[map];
}
if (typeof map === 'object') {
Object.entries(map).forEach(([name, mapping]) => midicontrolMap.set(name, unifyMapping(mapping)));
}
}
// registry for midi sounds, converting sound names to controls
export const midisoundMap = new Map();
// normalizes the given value from the given range and exponent
function normalize(value = 0, min = 0, max = 1, exp = 1) {
if (min === max) {
throw new Error('min and max cannot be the same value');
}
let normalized = (value - min) / (max - min);
normalized = Math.min(1, Math.max(0, normalized));
return Math.pow(normalized, exp);
}
function mapCC(mapping, value) {
return Object.keys(value)
.filter((key) => !!mapping[getControlName(key)])
.map((key) => {
const { ccn, min = 0, max = 1, exp = 1 } = mapping[key];
const ccv = normalize(value[key], min, max, exp);
return { ccn, ccv };
});
}
// sends a cc message to the given device on the given channel
function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
throw new Error('expected ccv to be a number between 0 and 1');
}
if (!['string', 'number'].includes(typeof ccn)) {
throw new Error('expected ccn to be a number or a string');
}
const scaled = Math.round(ccv * 127);
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
}
// sends a program change message to the given device on the given channel
function sendProgramChange(progNum, device, midichan, timeOffsetString) {
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
throw new Error('expected progNum (program change) to be a number between 0 and 127');
}
device.sendProgramChange(progNum, midichan, { time: timeOffsetString });
}
// sends a sysex message to the given device on the given channel
function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
if (Array.isArray(sysexid)) {
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysexid bytes must be integers between 0 and 255');
}
} else if (!Number.isInteger(sysexid) || sysexid < 0 || sysexid > 255) {
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
}
if (!Array.isArray(sysexdata)) {
throw new Error('expected sysex to be an array of numbers (0-255)');
}
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysex bytes must be integers between 0 and 255');
}
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString });
}
// sends a NRPN message to the given device on the given channel
function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
if (Array.isArray(nrpnn)) {
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all nrpnn bytes must be integers between 0 and 255');
}
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
}
device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString });
}
// sends a pitch bend message to the given device on the given channel
function sendPitchBend(midibend, device, midichan, timeOffsetString) {
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
throw new Error('expected midibend to be a number between -1 and 1');
}
device.sendPitchBend(midibend, midichan, { time: timeOffsetString });
}
// sends a channel aftertouch message to the given device on the given channel
function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
throw new Error('expected miditouch to be a number between 0 and 1');
}
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
}
// sends a note message to the given device on the given channel
function sendNote(note, velocity, duration, device, midichan, timeOffsetString) {
if (note == null || note === '') {
throw new Error('note cannot be null or empty');
}
if (velocity != null && (typeof velocity !== 'number' || velocity < 0 || velocity > 1)) {
throw new Error('velocity must be a number between 0 and 1');
}
if (duration != null && (typeof duration !== 'number' || duration < 0)) {
throw new Error('duration must be a positive number');
}
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity, duration });
device.playNote(midiNote, midichan, {
time: timeOffsetString,
});
}
/**
* MIDI output: Opens a MIDI output port.
* @param {string | number} midiport MIDI device name or index defaulting to 0
* @param {object} options Additional MIDI configuration options
* @example
* note("c4").midichan(1).midi('IAC Driver Bus 1')
* @example
* note("c4").midichan(1).midi('IAC Driver Bus 1', { controller: true, latency: 50 })
*/
Pattern.prototype.midi = function (midiport, options = {}) {
if (isPattern(midiport)) {
throw new Error(
`.midi does not accept Pattern input. Make sure to pass device name with single quotes. Example: .midi('${
`.midi does not accept Pattern input for midiport. Make sure to pass device name with single quotes. Example: .midi('${
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
}')`,
);
}
// For backward compatibility
if (typeof midiport === 'object') {
const { port, isController = false, ...configOptions } = midiport;
options = {
isController,
...configOptions,
...options, // Keep any options passed separately
};
midiport = port;
}
let midiConfig = {
// Default configuration values
isController: false, // Disable sending notes for midi controllers
latencyMs: 34, // Default latency to get audio engine to line up in ms
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
midichannel: 1, // Default MIDI channel
velocity: 0.9, // Default velocity
gain: 1, // Default gain
midimap: 'default', // Default MIDI map
midiport: midiport, // Store the port in the config
...options, // Override defaults with provided options
};
enableWebMidi({
onEnabled: ({ outputs }) => {
const device = getDevice(output, outputs);
const device = getDevice(midiConfig.midiport, outputs);
const otherOutputs = outputs.filter((o) => o.name !== device.name);
logger(
`Midi enabled! Using "${device.name}". ${
@@ -114,39 +335,102 @@ Pattern.prototype.midi = function (output) {
return this.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => {
if (!WebMidi.enabled) {
console.log('not enabled');
logger('Midi not enabled');
return;
}
const device = getDevice(output, WebMidi.outputs);
hap.ensureObjectValue();
//magic number to get audio engine to line up, can probably be calculated somehow
const latencyMs = 34;
const latencyMs = midiConfig.latencyMs;
// 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 = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`;
// destructure value
let { note, nrpnn, nrpv, ccn, ccv, midichan = 1, midicmd, gain = 1, velocity = 0.9 } = hap.value;
// midi event values from hap with configurable defaults
let {
note,
nrpnn,
nrpv,
ccn,
ccv,
midichan = midiConfig.midichannel,
midicmd,
midibend,
miditouch,
polyTouch,
gain = midiConfig.gain,
velocity = midiConfig.velocity,
progNum,
sysexid,
sysexdata,
midimap = midiConfig.midimap,
midiport = midiConfig.midiport,
} = hap.value;
const device = getDevice(midiport, WebMidi.outputs);
if (!device) {
logger(
`[midi] midiport "${midiport}" not found! available: ${WebMidi.outputs.map((output) => `'${output.name}'`).join(', ')}`,
);
return;
}
velocity = gain * velocity;
// note off messages will often a few ms arrive late, try to prevent glitching by subtracting from the duration length
const duration = (hap.duration.valueOf() / cps) * 1000 - 10;
if (note != null) {
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity, duration });
device.playNote(midiNote, midichan, {
time: timeOffsetString,
});
// Handle midimap
// if midimap is set, send a cc messages from defined controls
if (midicontrolMap.has(midimap)) {
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString));
} else if (midimap !== 'default') {
// Add warning when a non-existent midimap is specified
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
}
// Handle note
if (note !== undefined && !midiConfig.isController) {
// note off messages will often a few ms arrive late,
// try to prevent glitching by subtracting noteOffsetMs from the duration length
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
sendNote(note, velocity, duration, device, midichan, timeOffsetString);
}
// Handle program change
if (progNum !== undefined) {
sendProgramChange(progNum, device, midichan, timeOffsetString);
}
// Handle sysex
// sysex data is consist of 2 arrays, first is sysexid, second is sysexdata
// sysexid is a manufacturer id it is either a number or an array of 3 numbers.
// list of manufacturer ids can be found here : https://midi.org/sysexidtable
// if sysexid is an array the first byte is 0x00
if (sysexid !== undefined && sysexdata !== undefined) {
sendSysex(sysexid, sysexdata, device, timeOffsetString);
}
// Handle control change
if (ccv !== undefined && ccn !== undefined) {
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
throw new Error('expected ccv to be a number between 0 and 1');
}
if (!['string', 'number'].includes(typeof ccn)) {
throw new Error('expected ccn to be a number or a string');
}
const scaled = Math.round(ccv * 127);
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
sendCC(ccn, ccv, device, midichan, timeOffsetString);
}
// Handle NRPN non-registered parameter number
if (nrpnn !== undefined && nrpv !== undefined) {
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
}
// Handle midibend
if (midibend !== undefined) {
sendPitchBend(midibend, device, midichan, timeOffsetString);
}
// Handle miditouch
if (miditouch !== undefined) {
sendAftertouch(miditouch, device, midichan, timeOffsetString);
}
// Handle midicmd
if (hap.whole.begin + 0 === 0) {
// we need to start here because we have the timing info
device.sendStart({ time: timeOffsetString });
@@ -159,6 +443,19 @@ Pattern.prototype.midi = function (output) {
device.sendStop({ time: timeOffsetString });
} else if (['continue'].includes(midicmd)) {
device.sendContinue({ time: timeOffsetString });
} else if (Array.isArray(midicmd)) {
if (midicmd[0] === 'progNum') {
sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
} else if (midicmd[0] === 'cc') {
if (midicmd.length === 2) {
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString);
}
} else if (midicmd[0] === 'sysex') {
if (midicmd.length === 3) {
const [_, id, data] = midicmd;
sendSysex(id, data, device, timeOffsetString);
}
}
}
});
};
@@ -166,6 +463,14 @@ Pattern.prototype.midi = function (output) {
let listeners = {};
const refs = {};
/**
* MIDI input: Opens a MIDI input port to receive MIDI control change messages.
* @param {string | number} input MIDI device name or index defaulting to 0
* @returns {Function}
* @example
* let cc = await midin('IAC Driver Bus 1')
* note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth")
*/
export async function midin(input) {
if (isPattern(input)) {
throw new Error(
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -19,10 +19,10 @@ This program is free software: you can redistribute it and/or modify it under th
this.location_ = location();
}
var PatternStub = function(source, alignment, seed, tactus)
var PatternStub = function(source, alignment, seed, _steps)
{
this.type_ = "pattern";
this.arguments_ = { alignment: alignment, tactus: tactus };
this.arguments_ = { alignment: alignment, _steps: _steps };
if (seed !== undefined) {
this.arguments_.seed = seed;
}
@@ -172,8 +172,8 @@ slice_with_ops = s:slice ops:slice_op*
}
// a sequence is a combination of one or more successive slices (as an array)
sequence = tactus:'^'? s:(slice_with_ops)+
{ return new PatternStub(s, 'fastcat', undefined, !!tactus); }
sequence = _steps:'^'? s:(slice_with_ops)+
{ return new PatternStub(s, 'fastcat', undefined, !!_steps); }
// a stack is a series of vertically aligned sequence, separated by a comma
stack_tail = tail:(comma @sequence)+
+17 -17
View File
@@ -14,7 +14,7 @@ const applyOptions = (parent, enter) => (pat, i) => {
const ast = parent.source_[i];
const options = ast.options_;
const ops = options?.ops;
const tactus_source = pat.__tactus_source;
const steps_source = pat.__steps_source;
if (ops) {
for (const op of ops) {
switch (op.type_) {
@@ -69,7 +69,7 @@ const applyOptions = (parent, enter) => (pat, i) => {
}
}
}
pat.__tactus_source = pat.__tactus_source || tactus_source;
pat.__steps_source = pat.__steps_source || steps_source;
return pat;
};
@@ -82,20 +82,20 @@ export function patternifyAST(ast, code, onEnter, offset = 0) {
// resolveReplications(ast);
const children = ast.source_.map((child) => enter(child)).map(applyOptions(ast, enter));
const alignment = ast.arguments_.alignment;
const with_tactus = children.filter((child) => child.__tactus_source);
const with_steps = children.filter((child) => child.__steps_source);
let pat;
switch (alignment) {
case 'stack': {
pat = strudel.stack(...children);
if (with_tactus.length) {
pat.tactus = lcm(...with_tactus.map((x) => Fraction(x.tactus)));
if (with_steps.length) {
pat._steps = lcm(...with_steps.map((x) => Fraction(x._steps)));
}
break;
}
case 'polymeter_slowcat': {
pat = strudel.stack(...children.map((child) => child._slow(child.__weight)));
if (with_tactus.length) {
pat.tactus = lcm(...with_tactus.map((x) => Fraction(x.tactus)));
if (with_steps.length) {
pat._steps = lcm(...with_steps.map((x) => Fraction(x._steps)));
}
break;
}
@@ -111,8 +111,8 @@ export function patternifyAST(ast, code, onEnter, offset = 0) {
}
case 'rand': {
pat = strudel.chooseInWith(strudel.rand.early(randOffset * ast.arguments_.seed).segment(1), children);
if (with_tactus.length) {
pat.tactus = lcm(...with_tactus.map((x) => Fraction(x.tactus)));
if (with_steps.length) {
pat._steps = lcm(...with_steps.map((x) => Fraction(x._steps)));
}
break;
}
@@ -131,21 +131,21 @@ export function patternifyAST(ast, code, onEnter, offset = 0) {
...ast.source_.map((child, i) => [child.options_?.weight || strudel.Fraction(1), children[i]]),
);
pat.__weight = weightSum; // for polymeter
pat.tactus = weightSum;
if (with_tactus.length) {
pat.tactus = pat.tactus.mul(lcm(...with_tactus.map((x) => Fraction(x.tactus))));
pat._steps = weightSum;
if (with_steps.length) {
pat._steps = pat._steps.mul(lcm(...with_steps.map((x) => Fraction(x._steps))));
}
} else {
pat = strudel.sequence(...children);
pat.tactus = children.length;
pat._steps = children.length;
}
if (ast.arguments_.tactus) {
pat.__tactus_source = true;
if (ast.arguments_._steps) {
pat.__steps_source = true;
}
}
}
if (with_tactus.length) {
pat.__tactus_source = true;
if (with_steps.length) {
pat.__steps_source = true;
}
return pat;
}
+10 -10
View File
@@ -208,16 +208,16 @@ describe('mini', () => {
it('_ and @ are almost interchangeable', () => {
expect(minS('a @ b @ @')).toEqual(minS('a _2 b _3'));
});
it('supports ^ tactus marking', () => {
expect(mini('a [^b c]').tactus).toEqual(Fraction(4));
expect(mini('[^b c]!3').tactus).toEqual(Fraction(6));
expect(mini('[a b c] [d [e f]]').tactus).toEqual(Fraction(2));
expect(mini('^[a b c] [d [e f]]').tactus).toEqual(Fraction(2));
expect(mini('[a b c] [d [^e f]]').tactus).toEqual(Fraction(8));
expect(mini('[a b c] [^d [e f]]').tactus).toEqual(Fraction(4));
expect(mini('[^a b c] [^d [e f]]').tactus).toEqual(Fraction(12));
expect(mini('[^a b c] [d [^e f]]').tactus).toEqual(Fraction(24));
expect(mini('[^a b c d e]').tactus).toEqual(Fraction(5));
it('supports ^ step marking', () => {
expect(mini('a [^b c]')._steps).toEqual(Fraction(4));
expect(mini('[^b c]!3')._steps).toEqual(Fraction(6));
expect(mini('[a b c] [d [e f]]')._steps).toEqual(Fraction(2));
expect(mini('^[a b c] [d [e f]]')._steps).toEqual(Fraction(2));
expect(mini('[a b c] [d [^e f]]')._steps).toEqual(Fraction(8));
expect(mini('[a b c] [^d [e f]]')._steps).toEqual(Fraction(4));
expect(mini('[^a b c] [^d [e f]]')._steps).toEqual(Fraction(12));
expect(mini('[^a b c] [d [^e f]]')._steps).toEqual(Fraction(24));
expect(mini('[^a b c d e]')._steps).toEqual(Fraction(5));
});
});
+23 -8
View File
@@ -35,17 +35,13 @@ Pattern.prototype.mqtt = function (
host = 'wss://localhost:8883/',
client = undefined,
latency = 0,
add_meta = true,
) {
const key = host + '-' + client;
let connected = false;
let password_entered = false;
if (!client) {
client = 'strudel-' + String(Math.floor(Math.random() * 1000000));
}
function onConnect() {
console.log('Connected to mqtt broker');
connected = true;
if (password_entered) {
document.cookie = 'mqtt_pass=' + password;
}
@@ -55,7 +51,11 @@ Pattern.prototype.mqtt = function (
if (connections[key]) {
cx = connections[key];
} else {
if (!client) {
client = 'strudel-' + String(Math.floor(Math.random() * 1000000));
}
cx = new Paho.Client(host, client);
connections[key] = cx;
cx.onConnectionLost = onConnectionLost;
cx.onMessageArrived = onMessageArrived;
const props = {
@@ -83,17 +83,32 @@ Pattern.prototype.mqtt = function (
}
return this.withHap((hap) => {
const onTrigger = (t_deprecate, hap, currentTime, cps, targetTime) => {
if (!connected) {
let msg_topic = topic;
if (!cx || !cx.isConnected()) {
return;
}
let message = '';
if (typeof hap.value === 'object') {
message = JSON.stringify(hap.value);
let value = hap.value;
// Try to take topic from pattern if it's not set
if (typeof msg_topic === 'undefined' && 'topic' in value) {
msg_topic = value.topic;
if (Array.isArray(msg_topic)) {
msg_topic = msg_topic.join('/');
}
msg_topic = '/' + msg_topic;
}
if (add_meta) {
const duration = hap.duration.div(cps);
value = { ...value, duration: duration.valueOf(), cps: cps };
}
message = JSON.stringify(value);
} else {
message = hap.value;
}
message = new Paho.Message(message);
message.destinationName = topic;
message.destinationName = msg_topic;
const offset = (targetTime - currentTime + latency) * 1000;
+12
View File
@@ -94,3 +94,15 @@ or
The `.editor` property on the `strudel-editor` web component gives you the instance of [StrudelMirror](https://github.com/tidalcycles/strudel/blob/a46bd9b36ea7d31c9f1d3fca484297c7da86893f/packages/codemirror/codemirror.mjs#L124) that runs the REPL.
For example, you could use `setCode` to change the code from the outside, `start` / `stop` to toggle playback or `evaluate` to evaluate the code.
## Development: How to Test
```sh
cd packages/repl
pnpm build
cd ../.. # back to root folder
# edit ./examples/buildless/web-component-no-iframe.html
# use <script src="/packages/repl/dist/index.js"></script>
pnpx serve # from root folder
# go to http://localhost:3000/examples/buildless/web-component-no-iframe
```
+7 -1
View File
@@ -1,5 +1,5 @@
import { noteToMidi, valueToMidi, Pattern, evalScope } from '@strudel/core';
import { registerSynthSounds, registerZZFXSounds, samples } from '@strudel/webaudio';
import { aliasBank, registerSynthSounds, registerZZFXSounds, samples } from '@strudel/webaudio';
import * as core from '@strudel/core';
export async function prebake() {
@@ -21,6 +21,9 @@ export async function prebake() {
);
// load samples
const ds = 'https://raw.githubusercontent.com/felixroos/dough-samples/main/';
// TODO: move this onto the strudel repo
const ts = 'https://raw.githubusercontent.com/todepond/samples/main/';
await Promise.all([
modulesLoading,
registerSynthSounds(),
@@ -35,7 +38,10 @@ export async function prebake() {
samples(`${ds}/Dirt-Samples.json`),
samples(`${ds}/EmuSP12.json`),
samples(`${ds}/vcsl.json`),
samples(`${ds}/mridangam.json`),
]);
aliasBank(`${ts}/tidal-drum-machines-alias.json`);
}
const maxPan = noteToMidi('C8');
+7 -10
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import cowsay from 'cowsay';
import { createReadStream } from 'fs';
import { createReadStream, existsSync } from 'fs';
import { readdir } from 'fs/promises';
import http from 'http';
import { join, sep } from 'path';
@@ -70,12 +70,15 @@ const server = http.createServer(async (req, res) => {
return res.end(JSON.stringify(banks));
}
let subpath = decodeURIComponent(req.url);
if (!files.includes(subpath)) {
const filePath = join(directory, subpath.split('/').join(sep));
//console.log('GET:', filePath);
const isFound = existsSync(filePath);
if (!isFound) {
res.statusCode = 404;
res.end('File not found');
return;
}
const filePath = join(directory, subpath.split('/').join(sep));
const readStream = createReadStream(filePath);
readStream.on('error', (err) => {
res.statusCode = 500;
@@ -99,12 +102,6 @@ Object.keys(networkInterfaces).forEach((key) => {
});
});
if (!IP) {
console.error("Unable to determine server's IP address.");
// eslint-disable-next-line
process.exit(1);
}
server.listen(PORT, IP_ADDRESS, () => {
console.log(`@strudel/sampler is now serving audio files from:
${directory}
@@ -113,6 +110,6 @@ To use them in the Strudel REPL, run:
samples('http://localhost:${PORT}')
Or on a machine in the same network:
samples('http://${IP}:${PORT}')
${IP ? `samples('http://${IP}:${PORT}')` : `Unable to determine server's IP address.`}
`);
});
+8 -1
View File
@@ -9,6 +9,13 @@ import {
} from '@strudel/webaudio';
import gm from './gm.mjs';
let defaultSoundfontUrl = 'https://felixroos.github.io/webaudiofontdata/sound';
let soundfontUrl = defaultSoundfontUrl;
export function setSoundfontUrl(value) {
soundfontUrl = value;
}
let loadCache = {};
async function loadFont(name) {
if (loadCache[name]) {
@@ -16,7 +23,7 @@ async function loadFont(name) {
}
const load = async () => {
// TODO: make soundfont source configurable
const url = `https://felixroos.github.io/webaudiofontdata/sound/${name}.js`;
const url = `${soundfontUrl}/${name}.js`;
const preset = await fetch(url).then((res) => res.text());
let [_, data] = preset.split('={');
return eval('{' + data);
+2 -2
View File
@@ -1,6 +1,6 @@
import { getFontBufferSource, registerSoundfonts } from './fontloader.mjs';
import { getFontBufferSource, registerSoundfonts, setSoundfontUrl } from './fontloader.mjs';
import * as soundfontList from './list.mjs';
import { startPresetNote } from 'sfumato';
import { loadSoundfont } from './sfumato.mjs';
export { loadSoundfont, startPresetNote, getFontBufferSource, soundfontList, registerSoundfonts };
export { loadSoundfont, startPresetNote, getFontBufferSource, soundfontList, registerSoundfonts, setSoundfontUrl };
+2 -2
View File
@@ -3,11 +3,11 @@ import { getAudioContext, registerSound } from '@strudel/webaudio';
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
Pattern.prototype.soundfont = function (sf, n = 0) {
return this.onTrigger((t, h, ct) => {
return this.onTrigger((time_deprecate, h, ct, cps, targetTime) => {
const ctx = getAudioContext();
const note = getPlayableNoteValue(h);
const preset = sf.presets[n % sf.presets.length];
const deadline = ctx.currentTime + t - ct;
const deadline = targetTime;
const args = [ctx, preset, noteToMidi(note), deadline];
const stop = startPresetNote(...args);
stop(deadline + h.duration);
+64 -3
View File
@@ -17,11 +17,72 @@ import { loadBuffer } from './sampler.mjs';
export const soundMap = map();
export function registerSound(key, onTrigger, data = {}) {
soundMap.setKey(key, { onTrigger, data });
soundMap.setKey(key.toLowerCase(), { onTrigger, data });
}
function aliasBankMap(aliasMap) {
// Make all bank keys lower case for case insensitivity
for (const key in aliasMap) {
aliasMap[key.toLowerCase()] = aliasMap[key];
}
// Look through every sound...
const soundDictionary = soundMap.get();
for (const key in soundDictionary) {
// Check if the sound is part of a bank...
const [bank, suffix] = key.split('_');
if (!suffix) continue;
// Check if the bank is aliased...
const aliasValue = aliasMap[bank];
if (aliasValue) {
if (typeof aliasValue === 'string') {
// Alias a single alias
soundDictionary[`${aliasValue}_${suffix}`.toLowerCase()] = soundDictionary[key];
} else if (Array.isArray(aliasValue)) {
// Alias multiple aliases
for (const alias of aliasValue) {
soundDictionary[`${alias}_${suffix}`.toLowerCase()] = soundDictionary[key];
}
}
}
}
// Update the sound map!
// We need to destructure here to trigger the update
soundMap.set({ ...soundDictionary });
}
async function aliasBankPath(path) {
const response = await fetch(path);
const aliasMap = await response.json();
aliasBankMap(aliasMap);
}
/**
* Register an alias for a bank of sounds.
* Optionally accepts a single argument map of bank aliases.
* Optionally accepts a single argument string of a path to a JSON file containing bank aliases.
* @param {string} bank - The bank to alias
* @param {string} alias - The alias to use for the bank
*/
export async function aliasBank(...args) {
switch (args.length) {
case 1:
if (typeof args[0] === 'string') {
return aliasBankPath(args[0]);
} else {
return aliasBankMap(args[0]);
}
case 2:
return aliasBankMap({ [args[0]]: args[1] });
default:
throw new Error('aliasMap expects 1 or 2 arguments, received ' + args.length);
}
}
export function getSound(s) {
return soundMap.get()[s];
return soundMap.get()[s.toLowerCase()];
}
const defaultDefaultValues = {
@@ -314,6 +375,7 @@ export function resetGlobalEffects() {
}
export const superdough = async (value, t, hapDuration) => {
const ac = getAudioContext();
t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t;
let { stretch } = value;
if (stretch != null) {
@@ -321,7 +383,6 @@ export const superdough = async (value, t, hapDuration) => {
const latency = 0.04;
t = t - latency;
}
const ac = getAudioContext();
if (typeof value !== 'object') {
throw new Error(
`expected hap.value to be an object, but got "${value}". Hint: append .note() or .s() to the end`,
+59
View File
@@ -133,6 +133,65 @@ export function registerSynthSounds() {
{ prebake: true, type: 'synth' },
);
registerSound(
'pulse',
(begin, value, onended) => {
const ac = getAudioContext();
let { duration, n: pulsewidth = 0.5 } = value;
const frequency = getFrequencyFromValue(value);
const [attack, decay, sustain, release] = getADSRValues(
[value.attack, value.decay, value.sustain, value.release],
'linear',
[0.001, 0.05, 0.6, 0.01],
);
const holdend = begin + duration;
const end = holdend + release + 0.01;
let o = getWorklet(
ac,
'pulse-oscillator',
{
frequency,
begin,
end,
pulsewidth,
},
{
outputChannelCount: [2],
},
);
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
const fm = applyFM(o.parameters.get('frequency'), value, begin);
let envGain = gainNode(1);
envGain = o.connect(envGain);
webAudioTimeout(
ac,
() => {
o.disconnect();
envGain.disconnect();
onended();
fm?.stop();
vibratoOscillator?.stop();
},
begin,
end,
);
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
return {
node: envGain,
stop: (time) => {},
};
},
{ prebake: true, type: 'synth' },
);
[...noises].forEach((s) => {
registerSound(
s,
+112 -2
View File
@@ -75,7 +75,12 @@ const waveshapes = {
return v - polyBlep(phase, dt);
},
};
function getParamValue(block, param) {
if (param.length > 1) {
return param[block];
}
return param[0];
}
const waveShapeNames = Object.keys(waveshapes);
class LFOProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
@@ -362,6 +367,11 @@ function getUnisonDetune(unison, detune, voiceIndex) {
}
return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
}
function applySemitoneDetuneToFrequency(frequency, detune) {
return frequency * Math.pow(2, detune / 12);
}
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
constructor() {
super();
@@ -438,7 +448,7 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
const isOdd = (n & 1) == 1;
//applies unison "spread" detune in semitones
const freq = frequency * Math.pow(2, getUnisonDetune(voices, freqspread, n) / 12);
const freq = applySemitoneDetuneToFrequency(frequency, getUnisonDetune(voices, freqspread, n));
let gainL = gain1;
let gainR = gain2;
// invert right and left gain
@@ -648,3 +658,103 @@ class PhaseVocoderProcessor extends OLAProcessor {
}
registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor);
// Adapted from https://www.musicdsp.org/en/latest/Effects/221-band-limited-pwm-generator.html
class PulseOscillatorProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.pi = _PI;
this.phi = -this.pi; // phase
this.Y0 = 0; // feedback memories
this.Y1 = 0;
this.PW = this.pi; // pulse width
this.B = 2.3; // feedback coefficient
this.dphif = 0; // filtered phase increment
this.envf = 0; // filtered envelope
}
static get parameterDescriptors() {
return [
{
name: 'begin',
defaultValue: 0,
max: Number.POSITIVE_INFINITY,
min: 0,
},
{
name: 'end',
defaultValue: 0,
max: Number.POSITIVE_INFINITY,
min: 0,
},
{
name: 'frequency',
defaultValue: 440,
min: Number.EPSILON,
},
{
name: 'detune',
defaultValue: 0,
min: Number.NEGATIVE_INFINITY,
max: Number.POSITIVE_INFINITY,
},
{
name: 'pulsewidth',
defaultValue: 1,
min: 0,
max: Number.POSITIVE_INFINITY,
},
];
}
process(inputs, outputs, params) {
if (currentTime <= params.begin[0]) {
return true;
}
if (currentTime >= params.end[0]) {
return false;
}
const output = outputs[0];
let env = 1,
dphi;
for (let i = 0; i < (output[0].length ?? 0); i++) {
const pw = (1 - clamp(getParamValue(i, params.pulsewidth), -0.99, 0.99)) * this.pi;
const detune = getParamValue(i, params.detune);
const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100);
dphi = freq * (this.pi / (sampleRate * 0.5)); // phase increment
this.dphif += 0.1 * (dphi - this.dphif);
env *= 0.9998; // exponential decay envelope
this.envf += 0.1 * (env - this.envf);
// Feedback coefficient control
this.B = 2.3 * (1 - 0.0001 * freq); // feedback limitation
if (this.B < 0) this.B = 0;
// Waveform generation (half-Tomisawa oscillators)
this.phi += this.dphif; // phase increment
if (this.phi >= this.pi) this.phi -= 2 * this.pi; // phase wrapping
// First half-Tomisawa generator
let out0 = Math.cos(this.phi + this.B * this.Y0); // self-phase modulation
this.Y0 = 0.5 * (out0 + this.Y0); // anti-hunting filter
// Second half-Tomisawa generator (with phase offset for pulse width)
let out1 = Math.cos(this.phi + this.B * this.Y1 + pw);
this.Y1 = 0.5 * (out1 + this.Y1); // anti-hunting filter
for (let o = 0; o < output.length; o++) {
// Combination of both oscillators with envelope applied
output[o][i] = 0.15 * (out0 - out1) * this.envf;
}
}
return true; // keep the audio processing going
}
}
registerProcessor('pulse-oscillator', PulseOscillatorProcessor);
+1 -1
View File
@@ -5,7 +5,7 @@
"module": "tidal.mjs",
"repository": {
"type": "git",
"url": "git+https://github.com/felixroos/hs2js.git"
"url": "git+https://github.com/tidalcycles/strudel/tree/main/packages/tidal"
},
"keywords": [
"haskell",
+1 -1
View File
@@ -249,5 +249,5 @@ export const scale = register(
);
},
true,
true, // preserve tactus
true, // preserve step count
);
+108
View File
@@ -39,6 +39,9 @@ importers:
'@tauri-apps/cli':
specifier: ^2.2.7
version: 2.2.7
'@vitest/coverage-v8':
specifier: 3.0.4
version: 3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0))
'@vitest/ui':
specifier: ^3.0.4
version: 3.0.4(vitest@3.0.4)
@@ -267,6 +270,16 @@ importers:
packages/embed: {}
packages/gamepad:
dependencies:
'@strudel/core':
specifier: workspace:*
version: link:../core
devDependencies:
vite:
specifier: ^6.0.11
version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)
packages/hs2js:
dependencies:
web-tree-sitter:
@@ -640,6 +653,9 @@ importers:
'@strudel/draw':
specifier: workspace:*
version: link:../packages/draw
'@strudel/gamepad':
specifier: workspace:*
version: link:../packages/gamepad
'@strudel/hydra':
specifier: workspace:*
version: link:../packages/hydra
@@ -1412,6 +1428,10 @@ packages:
resolution: {integrity: sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@codemirror/autocomplete@6.18.4':
resolution: {integrity: sha512-sFAphGQIqyQZfP2ZBsSHV7xQvo9Py0rV0dW7W3IMRdS+zDuNb2l3no78CvUaWKGfzFjI4FTrLdUSj86IGb2hRA==}
@@ -1834,6 +1854,10 @@ packages:
'@isaacs/string-locale-compare@1.1.0':
resolution: {integrity: sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==}
'@istanbuljs/schema@0.1.3':
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
'@jest/schemas@29.6.3':
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -2818,6 +2842,15 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0
'@vitest/coverage-v8@3.0.4':
resolution: {integrity: sha512-f0twgRCHgbs24Dp8cLWagzcObXMcuKtAwgxjJV/nnysPAJJk1JiKu/W0gIehZLmkljhJXU/E0/dmuQzsA/4jhA==}
peerDependencies:
'@vitest/browser': 3.0.4
vitest: 3.0.4
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@3.0.4':
resolution: {integrity: sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg==}
@@ -4487,6 +4520,9 @@ packages:
hs2js@0.1.0:
resolution: {integrity: sha512-THlUIMX8tZf6gtbz5RUZ8xQUyKJEItsx7bxEBcouFIEWjeo90376WMocj3JEz6qTv5nM+tjo3vNvLf89XruMvg==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
@@ -4837,6 +4873,22 @@ packages:
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
engines: {node: '>=0.10.0'}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-lib-source-maps@5.0.6:
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
engines: {node: '>=10'}
istanbul-reports@3.1.7:
resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==}
engines: {node: '>=8'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@@ -6887,6 +6939,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
text-encoding-shim@1.0.5:
resolution: {integrity: sha512-H7yYW+jRn4yhu60ygZ2f/eMhXPITRt4QSUTKzLm+eCaDsdX8avmgWpmtmHAzesjBVUTAypz9odu5RKUjX5HNYA==}
@@ -7505,6 +7561,7 @@ packages:
workbox-google-analytics@7.0.0:
resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==}
deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained
workbox-navigation-preload@7.0.0:
resolution: {integrity: sha512-juWCSrxo/fiMz3RsvDspeSLGmbgC0U9tKqcUPZBCf35s64wlaLXyn2KdHHXVQrb2cqF7I0Hc9siQalainmnXJA==}
@@ -8568,6 +8625,8 @@ snapshots:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@bcoe/v8-coverage@1.0.2': {}
'@codemirror/autocomplete@6.18.4':
dependencies:
'@codemirror/language': 6.10.8
@@ -8934,6 +8993,8 @@ snapshots:
'@isaacs/string-locale-compare@1.1.0': {}
'@istanbuljs/schema@0.1.3': {}
'@jest/schemas@29.6.3':
dependencies:
'@sinclair/typebox': 0.27.8
@@ -10187,6 +10248,24 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
debug: 4.4.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.1.7
magic-string: 0.30.17
magicast: 0.3.5
std-env: 3.8.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.0.4':
dependencies:
'@vitest/spy': 3.0.4
@@ -12202,6 +12281,8 @@ snapshots:
dependencies:
web-tree-sitter: 0.20.8
html-escaper@2.0.2: {}
html-escaper@3.0.3: {}
html-void-elements@3.0.0: {}
@@ -12540,6 +12621,27 @@ snapshots:
isobject@3.0.1: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.25
debug: 4.4.0
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
istanbul-reports@3.1.7:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -15262,6 +15364,12 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
test-exclude@7.0.1:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.4.5
minimatch: 9.0.5
text-encoding-shim@1.0.5: {}
text-extensions@1.9.0: {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -18,6 +18,8 @@ const skippedExamples = [
'accelerationZ',
'accelerationY',
'accelerationX',
'defaultmidimap',
'midimaps',
];
describe('runs examples', () => {
+13 -1
View File
@@ -11,7 +11,7 @@ import * as webaudio from '@strudel/webaudio';
import { mini, m } from '@strudel/mini/mini.mjs';
// import * as voicingHelpers from '@strudel/tonal/voicings.mjs';
// import euclid from '@strudel/core/euclid.mjs';
// import '@strudel/midi/midi.mjs';
//import '@strudel/midi/midi.mjs';
import * as tonalHelpers from '@strudel/tonal';
import '@strudel/xen/xen.mjs';
// import '@strudel/xen/tune.mjs';
@@ -21,6 +21,9 @@ import '@strudel/xen/xen.mjs';
// import '@strudel/webaudio/webaudio.mjs';
// import '@strudel/serial/serial.mjs';
import '../website/src/repl/piano';
//import * as motionHelpers from '../packages/motion/index.mjs';
//import * as geolocationHelpers from '../packages/geolocation/index.mjs';
import * as gamepadHelpers from '../packages/gamepad/index.mjs';
class MockedNode {
chain() {
@@ -123,6 +126,12 @@ const loadCsound = () => {};
const loadCSound = () => {};
const loadcsound = () => {};
const midin = () => {
return (ccNum) => strudel.ref(() => 0); // returns ref with default value 0
};
const sysex = ([id, data]) => {};
// TODO: refactor to evalScope
evalScope(
// Tone,
@@ -131,6 +140,7 @@ evalScope(
uiHelpersMocked,
webaudio,
tonalHelpers,
gamepadHelpers,
/*
toneHelpers,
voicingHelpers,
@@ -138,6 +148,8 @@ evalScope(
uiHelpers,
*/
{
midin,
sysex,
// gist,
// euclid,
csound: id,
+1
View File
@@ -29,6 +29,7 @@
"@strudel/csound": "workspace:*",
"@strudel/desktopbridge": "workspace:*",
"@strudel/draw": "workspace:*",
"@strudel/gamepad": "workspace:*",
"@strudel/hydra": "workspace:*",
"@strudel/midi": "workspace:*",
"@strudel/mini": "workspace:*",
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
100% free for personal and commercial use.
However it's limited on basic latin only,
contact riedjal@gmail.com for full glyph (based on ANSI encoding)
and OTF features (alternates).
src: https://www.dafont.com/cute-aurora.font?text=%24%3A+s%28%22bd%285%2C8%29%22%29.superimpose%28x+%3D%3E+x.note%28%22c2%22%29.midi%28device%29%29
+160
View File
@@ -0,0 +1,160 @@
{
"_base": "https://raw.githubusercontent.com/yaxu/mrid/main/",
"mridangam_gumki": [
"norm_sounds/gumki/gumki2-3.wav",
"norm_sounds/gumki/gumki2-7.wav",
"norm_sounds/gumki/gumki2-6.wav",
"norm_sounds/gumki/gumki2-5.wav",
"norm_sounds/gumki/gumki-4.wav",
"norm_sounds/gumki/gumki-2.wav",
"norm_sounds/gumki/gumki2-4.wav",
"norm_sounds/gumki/gumki-5.wav",
"norm_sounds/gumki/gumki2-8.wav",
"norm_sounds/gumki/gumki-6.wav",
"norm_sounds/gumki/gumki-1.wav",
"norm_sounds/gumki/gumki2-2.wav",
"norm_sounds/gumki/gumki-3.wav",
"norm_sounds/gumki/gumki2-1.wav"
],
"mridangam_ka": [
"norm_sounds/ka/ka2-4.wav",
"norm_sounds/ka/ka2-2.wav",
"norm_sounds/ka/ka2-1.wav",
"norm_sounds/ka/ka-2.wav",
"norm_sounds/ka/ka-5.wav",
"norm_sounds/ka/ka-7.wav",
"norm_sounds/ka/ka-6.wav",
"norm_sounds/ka/ka2-5.wav",
"norm_sounds/ka/ka-1.wav",
"norm_sounds/ka/ka-4.wav",
"norm_sounds/ka/ka2-3.wav",
"norm_sounds/ka/ka-3.wav"
],
"mridangam_nam": [
"norm_sounds/nam/nam2-1.wav",
"norm_sounds/nam/nam2-3.wav",
"norm_sounds/nam/nam-3.wav",
"norm_sounds/nam/nam-2.wav",
"norm_sounds/nam/nam2-5.wav",
"norm_sounds/nam/nam2-2.wav",
"norm_sounds/nam/nam2-4.wav",
"norm_sounds/nam/nam-1.wav"
],
"mridangam_ta": [
"norm_sounds/ta/ta-3.wav",
"norm_sounds/ta/ta2-2.wav",
"norm_sounds/ta/ta-2.wav",
"norm_sounds/ta/ta2-3.wav",
"norm_sounds/ta/ta2-6.wav",
"norm_sounds/ta/ta2-4.wav",
"norm_sounds/ta/ta2-1.wav",
"norm_sounds/ta/ta2-5.wav",
"norm_sounds/ta/ta-1.wav"
],
"mridangam_ki": [
"norm_sounds/ki/ki2-3.wav",
"norm_sounds/ki/ki2-1.wav",
"norm_sounds/ki/ki-2.wav",
"norm_sounds/ki/ki-1.wav",
"norm_sounds/ki/ki2-4.wav",
"norm_sounds/ki/ki2-2.wav",
"norm_sounds/ki/ki-3.wav"
],
"mridangam_dhin": [
"norm_sounds/dhin/dhin2-3.wav",
"norm_sounds/dhin/dhin-2.wav",
"norm_sounds/dhin/dhin2-5.wav",
"norm_sounds/dhin/dhin-3.wav",
"norm_sounds/dhin/dhin2-4.wav",
"norm_sounds/dhin/dhin2-2.wav",
"norm_sounds/dhin/dhin-1.wav",
"norm_sounds/dhin/dhin2-1.wav"
],
"mridangam_na": [
"norm_sounds/na/na-6.wav",
"norm_sounds/na/na2-5.wav",
"norm_sounds/na/na-3.wav",
"norm_sounds/na/na2-2.wav",
"norm_sounds/na/na-7.wav",
"norm_sounds/na/na2-1.wav",
"norm_sounds/na/na2-3.wav",
"norm_sounds/na/na-2.wav",
"norm_sounds/na/na-4.wav",
"norm_sounds/na/na-5.wav",
"norm_sounds/na/na-1.wav",
"norm_sounds/na/na2-4.wav"
],
"mridangam_chaapu": [
"norm_sounds/c/chaapu-3.wav",
"norm_sounds/c/chaapu2-9.wav",
"norm_sounds/c/chaapu2-4.wav",
"norm_sounds/c/chaapu2-3.wav",
"norm_sounds/c/chaapu2-6.wav",
"norm_sounds/c/chaapu-1.wav",
"norm_sounds/c/chaapu2-8.wav",
"norm_sounds/c/chaapu2-1.wav",
"norm_sounds/c/chaapu2-2.wav",
"norm_sounds/c/chaapu2-5.wav",
"norm_sounds/c/chaapu-4.wav",
"norm_sounds/c/chaapu2-7.wav",
"norm_sounds/c/chaapu-2.wav"
],
"mridangam_dhum": [
"norm_sounds/dhum/dhum-1.wav",
"norm_sounds/dhum/dhum2-3.wav",
"norm_sounds/dhum/dhum2-1.wav",
"norm_sounds/dhum/dhum-2.wav",
"norm_sounds/dhum/dhum2-2.wav",
"norm_sounds/dhum/dhum-3.wav",
"norm_sounds/dhum/dhum2-4.wav"
],
"mridangam_ardha": [
"norm_sounds/ac/ardha-chaapu2-3.wav",
"norm_sounds/ac/ardha-chaapu2-14.wav",
"norm_sounds/ac/ardha-chaapu2-2.wav",
"norm_sounds/ac/ardha-chaapu2-10.wav",
"norm_sounds/ac/ardha-chaapu-5.wav",
"norm_sounds/ac/ardha-chaapu2-6.wav",
"norm_sounds/ac/ardha-chaapu-3.wav",
"norm_sounds/ac/ardha-chaapu2-4.wav",
"norm_sounds/ac/ardha-chaapu-2.wav",
"norm_sounds/ac/ardha-chaapu2-12.wav",
"norm_sounds/ac/ardha-chaapu2-5.wav",
"norm_sounds/ac/ardha-chaapu-6.wav",
"norm_sounds/ac/ardha-chaapu2-9.wav",
"norm_sounds/ac/ardha-chaapu2-13.wav",
"norm_sounds/ac/ardha-chaapu2-1.wav",
"norm_sounds/ac/ardha-chaapu-4.wav",
"norm_sounds/ac/ardha-chaapu2-11.wav",
"norm_sounds/ac/ardha-chaapu2-7.wav",
"norm_sounds/ac/ardha-chaapu2-8.wav",
"norm_sounds/ac/ardha-chaapu-1.wav"
],
"mridangam_thom": [
"norm_sounds/thom/thom2-3.wav",
"norm_sounds/thom/thom-2.wav",
"norm_sounds/thom/thom-3.wav",
"norm_sounds/thom/thom-1.wav",
"norm_sounds/thom/thom2-4.wav",
"norm_sounds/thom/thom2-2.wav",
"norm_sounds/thom/thom2-1.wav"
],
"mridangam_dhi": [
"norm_sounds/dhi/dhi-3.wav",
"norm_sounds/dhi/dhi2-4.wav",
"norm_sounds/dhi/dhi2-1.wav",
"norm_sounds/dhi/dhi2-3.wav",
"norm_sounds/dhi/dhi-1.wav",
"norm_sounds/dhi/dhi2-2.wav",
"norm_sounds/dhi/dhi-2.wav"
],
"mridangam_tha": [
"norm_sounds/tha/tha2-3.wav",
"norm_sounds/tha/tha2-2.wav",
"norm_sounds/tha/tha2-4.wav",
"norm_sounds/tha/tha-1.wav",
"norm_sounds/tha/tha-3.wav",
"norm_sounds/tha/tha2-1.wav",
"norm_sounds/tha/tha-2.wav"
]
}
@@ -0,0 +1,68 @@
{
"AJKPercusyn": "Percysyn",
"AkaiLinn": "Linn",
"AkaiMPC60": "MPC60",
"AkaiXR10": "XR10",
"AlesisHR16": "HR16",
"AlesisSR16": "SR16",
"BossDR110": "DR110",
"BossDR220": "DR220",
"BossDR55": "DR55",
"BossDR550": "DR550",
"CasioRZ1": "RZ1",
"CasioSK1": "SK1",
"CasioVL1": "VL1",
"DoepferMS404": "MS404",
"EmuDrumulator": "Drumulator",
"EmuSP12": "SP12",
"KorgDDM110": "DDM110",
"KorgKPR77": "KPR77",
"KorgKR55": "KR55",
"KorgKRZ": "KRZ",
"KorgM1": "M1",
"KorgMinipops": "Minipops",
"KorgPoly800": "Poly800",
"KorgT3": "T3",
"Linn9000": "9000",
"LinnLM1": "LM1",
"LinnLM2": "LM2",
"MoogConcertMateMG1": "ConcertMateMG1",
"OberheimDMX": "DMX",
"RhodesPolaris": "Polaris",
"RhythmAce": "Ace",
"RolandCompurhythm1000": "Compurhythm1000",
"RolandCompurhythm78": "Compurhythm78",
"RolandCompurhythm8000": "Compurhythm8000",
"RolandD110": "D110",
"RolandD70": "D70",
"RolandDDR30": "DDR30",
"RolandJD990": "JD990",
"RolandMC202": "MC202",
"RolandMC303": "MC303",
"RolandMT32": "MT32",
"RolandR8": "R8",
"RolandS50": "S50",
"RolandSH09": "SH09",
"RolandSystem100": "System100",
"RolandTR505": "TR505",
"RolandTR606": "TR606",
"RolandTR626": "TR626",
"RolandTR707": "TR707",
"RolandTR727": "TR727",
"RolandTR808": "TR808",
"RolandTR909": "TR909",
"SakataDPM48": "DPM48",
"SequentialCircuitsDrumtracks": "CircuitsDrumtracks",
"SequentialCircuitsTom": "CircuitsTom",
"SimmonsSDS400": "SDS400",
"SimmonsSDS5": "SDS5",
"SoundmastersR88": "R88",
"UnivoxMicroRhythmer12": "MicroRhythmer12",
"ViscoSpaceDrum": "SpaceDrum",
"XdrumLM8953": "LM8953",
"YamahaRM50": "RM50",
"YamahaRX21": "RX21",
"YamahaRX5": "RX5",
"YamahaRY30": "RY30",
"YamahaTG33": "TG33"
}
+5 -3
View File
@@ -36,8 +36,6 @@ export function Showcase() {
}
let _videos = [
{ title: 'Coding Music With Strudel Workhop by Dan Gorelick and Viola He', id: 'oqyAJ4WeKoU' },
{ title: 'Hexe - playing w strudel live coding music', id: '03m3F5xVOMg' },
{ title: 'DJ_Dave - Array [Lil Data Edit]', id: 'KUujFuTcuKc' },
{ title: 'DJ_Dave - Bitrot [v10101a Edit]', id: 'z_cJMdBp67Q' },
{ title: 'you will not steve reich your way out of it', id: 'xpILnXcWyuo' },
@@ -58,7 +56,6 @@ let _videos = [
},
{ title: 'letSeaTstrudeL @ solstice stream 2023', id: 'fTiX6dVtdWQ' },
{ title: 'totalgee (Glen F) @ solstice stream 2023', id: 'IvI6uaE3nLU' },
{ title: 'Dan Gorelick @ solstice stream 2023', id: 'qMJEljJyPi0' },
//
/* { // not sure if this is copyrighted ...
title: 'Creative Coding @ Chalmers University of Technology, video by svt.se',
@@ -126,6 +123,11 @@ let _videos = [
'A first foray into combining (an early version) strudel and hydra, using flok for collaborative coding.',
},
{ title: 'froos @ Algorave 10th Birthday stream', id: 'IcMSocdKwvw' },
{ title: 'todepasta 1.5', id: 'gCwaVu1Mijg' },
{ title: 'Djenerative Music by Bogdan Vera @ TOPLAP solstice Dec 2024', id: 'LtMX4Lr1nzY' },
{ title: 'La musique by BuboBubo @ TOPLAP solstice Dec 2024', id: 'Oz00Y_f80wU' },
{ title: 'Livecode and vocal breaks by Switch Angel @ TOPLAP solstice Dec 2024', id: '2kzjOIsL6CM' },
{ title: 'Eddyflux algorave set @ rudolf5', id: 'MXz8131Ut0A' },
];
_shuffled = shuffleArray(_videos);
+2 -2
View File
@@ -9,11 +9,11 @@ import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage'
// }
export default function UdelsEditor(Props) {
const { context } = Props;
const { context, ...editorProps } = Props;
const { containerRef, editorRef, error, init, pending, started, handleTogglePlay } = context;
return (
<div className={'h-full flex w-full flex-col relative'}>
<div className={'h-full flex w-full flex-col relative'} {...editorProps}>
<Loader active={pending} />
<BigPlayButton started={started} handleTogglePlay={handleTogglePlay} />
<div className="grow flex relative overflow-hidden">
+1 -1
View File
@@ -4,7 +4,7 @@ export default function UdelsHeader(Props) {
const { numWindows, setNumWindows } = Props;
return (
<header id="header" className="flex text-white z-[100] text-lg select-none bg-neutral-900">
<header id="header" className="flex text-white z-[100] text-lg select-none bg-neutral-800">
<div className="px-4 items-center gap-2 flex space-x-2 md:pt-0 select-none">
<h1 onClick={() => {}} className={'text-l cursor-pointer flex gap-4'}>
<div className={'mt-[1px] cursor-pointer'}>🌀</div>
+2
View File
@@ -84,6 +84,7 @@ export const SIDEBAR: Sidebar = {
{ text: 'Music metadata', link: 'learn/metadata' },
{ text: 'CSound', link: 'learn/csound' },
{ text: 'Hydra', link: 'learn/hydra' },
{ text: 'Input Devices', link: 'learn/input-devices' },
{ text: 'Device Motion', link: 'learn/devicemotion' },
],
'Pattern Functions': [
@@ -96,6 +97,7 @@ export const SIDEBAR: Sidebar = {
{ text: 'Conditional Modifiers', link: 'learn/conditional-modifiers' },
{ text: 'Accumulation', link: 'learn/accumulation' },
{ text: 'Tonal Functions', link: 'learn/tonal' },
{ text: 'Stepwise Functions', link: 'learn/stepwise' },
],
Understand: [
{ text: 'Coding syntax', link: 'learn/code' },
+4
View File
@@ -1,6 +1,10 @@
const ALLOW_MANY = ['by', 'url', 'genre', 'license'];
export function getMetadata(raw_code) {
if (raw_code == null) {
console.error('could not extract metadata from missing pattern code');
raw_code = '';
}
const comment_regexp = /\/\*([\s\S]*?)\*\/|\/\/(.*)$/gm;
const comments = [...raw_code.matchAll(comment_regexp)].map((c) => (c[1] || c[2] || '').trim());
const tags = {};
+1 -1
View File
@@ -5,6 +5,6 @@ layout: ../../layouts/MainLayout.astro
import { MiniRepl } from '../../docs/MiniRepl';
import { JsDoc } from '../../docs/JsDoc';
import DeviceMotion from '../../../../packages/motion/docs/devicemotion.mdx';
import DeviceMotion from '@strudel/motion/docs/devicemotion.mdx';
<DeviceMotion />
+2 -2
View File
@@ -293,8 +293,6 @@ global effects use the same chain for all events of the same orbit:
<JsDoc client:idle name="iresponse" h={0} />
Next, we'll look at strudel's support for [Csound](/learn/csound).
## Phaser
### phaser
@@ -312,3 +310,5 @@ Next, we'll look at strudel's support for [Csound](/learn/csound).
### phasersweep
<JsDoc client:idle name="phasersweep" h={0} />
Next, we'll look at input / output via [MIDI, OSC and other methods](/learn/input-output).
+15 -15
View File
@@ -11,15 +11,15 @@ import { JsDoc } from '../../docs/JsDoc';
The following functions will return a pattern.
These are the equivalents used by the Mini Notation:
| function | mini |
| -------------------------------- | ---------------- |
| `cat(x, y)` | `"<x y>"` |
| `seq(x, y)` | `"x y"` |
| `stack(x, y)` | `"x,y"` |
| `s_cat([3,x],[2,y])` | `"x@3 y@2"` |
| `s_polymeter([a, b, c], [x, y])` | `"{a b c, x y}"` |
| `s_polymeterSteps(2, x, y, z)` | `"{x y z}%2"` |
| `silence` | `"~"` |
| function | mini |
| ------------------------------ | ---------------- |
| `cat(x, y)` | `"<x y>"` |
| `seq(x, y)` | `"x y"` |
| `stack(x, y)` | `"x,y"` |
| `stepcat([3,x],[2,y])` | `"x@3 y@2"` |
| `polymeter([a, b, c], [x, y])` | `"{a b c, x y}"` |
| `polymeterSteps(2, x, y, z)` | `"{x y z}%2"` |
| `silence` | `"~"` |
## cat
@@ -33,21 +33,21 @@ These are the equivalents used by the Mini Notation:
<JsDoc client:idle name="stack" h={0} />
## s_cat
## stepcat
<JsDoc client:idle name="s_cat" h={0} />
<JsDoc client:idle name="stepcat" h={0} />
## arrange
<JsDoc client:idle name="arrange" h={0} />
## s_polymeter
## polymeter
<JsDoc client:idle name="s_polymeter" h={0} />
<JsDoc client:idle name="polymeter" h={0} />
## s_polymeterSteps
## polymeterSteps
<JsDoc client:idle name="s_polymeterSteps" h={0} />
<JsDoc client:idle name="polymeterSteps" h={0} />
## silence
+15
View File
@@ -0,0 +1,15 @@
---
title: Input Devices
layout: ../../layouts/MainLayout.astro
---
import { MiniRepl } from '../../docs/MiniRepl';
import { JsDoc } from '../../docs/JsDoc';
import Gamepad from '@strudel/gamepad/docs/gamepad.mdx';
# Input Devices
Strudel supports various input devices like Gamepads and MIDI controllers to manipulate patterns in real-time.
<Gamepad />
+145 -12
View File
@@ -16,24 +16,97 @@ It is also possible to pattern other things with Strudel, such as software and h
Strudel supports MIDI without any additional software (thanks to [webmidi](https://npmjs.com/package/webmidi)), just by adding methods to your pattern:
## midi(outputName?)
## midiin(inputName?)
<JsDoc client:idle name="midin" h={0} />
## midi(outputName?,options?)
Either connect a midi device or use the IAC Driver (Mac) or Midi Through Port (Linux) for internal midi messages.
If no outputName is given, it uses the first midi output it finds.
<MiniRepl client:idle tune={`chord("<C^7 A7 Dm7 G7>").voicing().midi()`} />
<MiniRepl
client:idle
tune={`
$: chord("<C^7 A7 Dm7 G7>").voicing().midi('IAC Driver')
`}
/>
In the console, you will see a log of the available MIDI devices as soon as you run the code, e.g. `Midi connected! Using "Midi Through Port-0".`
In the console, you will see a log of the available MIDI devices as soon as you run the code,
e.g.
```
`Midi connected! Using "Midi Through Port-0".`
```
The `.midi()` function accepts an options object with the following properties:
<MiniRepl
client:idle
tune={`$: note("d e c a f").midi('IAC Driver', { isController: true, midimap: 'default'})
`}
/>
<details>
<summary>Available Options</summary>
| Option | Type | Default | Description |
| ------------ | ------------- | --------- | ---------------------------------------------------------------------- |
| isController | boolean | false | When true, disables sending note messages. Useful for MIDI controllers |
| latencyMs | number | 34 | Latency in milliseconds to align MIDI with audio engine |
| noteOffsetMs | number | 10 | Offset in milliseconds for note-off messages to prevent glitching |
| midichannel | number | 1 | Default MIDI channel (1-16) |
| velocity | number | 0.9 | Default note velocity (0-1) |
| gain | number | 1 | Default gain multiplier for velocity (0-1) |
| midimap | string | 'default' | Name of MIDI mapping to use for control changes |
| midiport | string/number | - | MIDI device name or index |
</details>
### midiport(outputName)
Selects the MIDI output device to use, pattern can be used to switch between devices.
```javascript
$: midiport('IAC Driver');
$: note('c a f e').midiport('<0 1 2 3>').midi();
```
<JsDoc client:idle name="midiport" h={0} />
## midichan(number)
Selects the MIDI channel to use. If not used, `.midi` will use channel 1 by default.
## ccn && ccv
## midicmd(command)
`midicmd` sends MIDI system real-time messages to control timing and transport on MIDI devices.
It supports the following commands:
- `clock`/`midiClock` - Sends MIDI timing clock messages
- `start` - Sends MIDI start message
- `stop` - Sends MIDI stop message
- `continue` - Sends MIDI continue message
// You can control the clock with a pattern and ensure it starts in sync when the repl begins.
// Note: It might act unexpectedly if MIDI isn't set up initially.
<MiniRepl
client:idle
tune={`$:stack(
midicmd("clock*48,<start stop>/2").midi('IAC Driver')
)`}
/>
## control, ccn && ccv
- `control` sends MIDI control change messages to your MIDI device.
- `ccn` sets the cc number. Depends on your synths midi mapping
- `ccv` sets the cc value. normalized from 0 to 1.
<MiniRepl client:idle tune={`note("c a f e").control([74, sine.slow(4)]).midi()`} />
<MiniRepl client:idle tune={`note("c a f e").ccn(74).ccv(sine.slow(4)).midi()`} />
In the above snippet, `ccn` is set to 74, which is the filter cutoff for many synths. `ccv` is controlled by a saw pattern.
@@ -46,6 +119,58 @@ But you can also control cc messages separately like this:
$: ccv(sine.segment(16).slow(4)).ccn(74).midi()`}
/>
Instead of setting `ccn` and `ccv` directly, you can also create mappings with `midimaps`:
## midimaps
<JsDoc client:idle name="midimaps" h={0} />
## defaultmidimap
<JsDoc client:idle name="defaultmidimap" h={0} />
## progNum (Program Change)
`progNum` sends MIDI program change messages to switch between different presets/patches on your MIDI device.
Program change values should be numbers between 0 and 127.
<MiniRepl client:idle tune={`// Switch between programs 0 and 1 every cycle
progNum("<0 1>").midi()
// Play notes while changing programs
note("c3 e3 g3").progNum("<0 1 2>").midi()`} />
Program change messages are useful for switching between different instrument sounds or presets during a performance.
The exact sound that each program number maps to depends on your MIDI device's configuration.
## sysex, sysexid && sysexdata (System Exclusive Message)
`sysex` sends MIDI System Exclusive (SysEx) messages to your MIDI device.
ysEx messages are device-specific commands that allow deeper control over synthesizer parameters.
The value should be an array of numbers between 0-255 representing the SysEx data bytes.
<MiniRepl
client:idle
tune={`// Send a simple SysEx message
let id = 0x43; //Yamaha
//let id = "0x00:0x20:0x32"; //Behringer ID can be an array of numbers
let data = "0x79:0x09:0x11:0x0A:0x00:0x00"; // Set NSX-39 voice to say "Aa"
$: note("c a f e").sysex(id, data).midi();
$: note("c a f e").sysexid(id).sysexdata(data).midi();`}
/>
The exact format of SysEx messages depends on your MIDI device's specification.
Consult your device's MIDI implementation guide for details on supported SysEx messages.
## midibend && miditouch
`midibend` sets MIDI pitch bend (-1 - 1)
`miditouch` sets MIDI key after touch (0-1)
<MiniRepl client:idle tune={`note("c a f e").midibend(sine.slow(4).range(-0.4,0.4)).midi()`} />
<MiniRepl client:idle tune={`note("c a f e").miditouch(sine.slow(4).range(0,1)).midi()`} />
# OSC/SuperDirt/StrudelDirt
In TidalCycles, sound is usually generated using [SuperDirt](https://github.com/musikinformatik/SuperDirt/), which runs inside SuperCollider. Strudel also supports using SuperDirt, although it requires installing some additional software.
@@ -108,8 +233,8 @@ The following example shows how to send a pattern to an MQTT broker:
client:only="react"
tune={`"hello world"
.mqtt(undefined, // username (undefined for open/public servers)
undefined, // password
'/strudel-pattern', // mqtt 'topic'
undefined, // password
'/strudel-pattern', // mqtt 'topic'
'wss://mqtt.eclipseprojects.io:443/mqtt', // MQTT server address
'mystrudel', // MQTT client id - randomly generated if not supplied
0 // latency / delay before sending messages (0 = no delay)
@@ -120,12 +245,14 @@ The following example shows how to send a pattern to an MQTT broker:
Other software can then receive the messages. For example using the [mosquitto](https://mosquitto.org/) commandline client tools:
```
> mosquitto_sub -h mqtt.eclipseprojects.io -p 1883 -t "/strudel-pattern"
hello
world
hello
world
...
> mosquitto_sub -h mqtt.eclipseprojects.io -p 1883 -t "/strudel-pattern"
> hello
> world
> hello
> world
> ...
```
Control patterns will be encoded as JSON, for example:
@@ -145,11 +272,17 @@ Control patterns will be encoded as JSON, for example:
Will send messages like the following:
```
{"s":"sax","speed":2}
{"s":"sax","speed":2}
{"s":"sax","speed":3}
{"s":"sax","speed":2}
...
```
Libraries for receiving MQTT are available for many programming languages.
```
```
+133
View File
@@ -0,0 +1,133 @@
---
title: Stepwise patterning
layout: ../../layouts/MainLayout.astro
---
import { MiniRepl } from '../../docs/MiniRepl';
import { JsDoc } from '../../docs/JsDoc';
# Stepwise patterning (experimental)
This is a developing area of strudel, and behaviour might change or be renamed in future versions. Feedback and ideas are welcome!
## Introduction
Usually in strudel, the only reference point for most pattern transformations is the _cycle_. Now it is possible to also work with _steps_, via a growing range of functions.
For example usually when you `fastcat` two patterns together, the cycles will be squashed into half a cycle each:
<MiniRepl client:idle tune={`fastcat("bd hh hh", "bd hh hh cp hh").sound()`} />
With the new stepwise `stepcat` function, the steps of the two patterns will be evenly distributed across the cycle:
<MiniRepl client:idle tune={`stepcat("bd hh hh", "bd hh hh cp hh").sound()`} />
By default, steps are counted according to the 'top level' in mini-notation. For example `"a [b c] d e"` has five events in it per cycle, but is counted as four steps, where `[b c]` is counted as a single step.
However, you can mark a different metrical level to count steps relative to, using a `^` at the start of a sub-pattern. If we do this to the subpattern in our example: `"a [^b c] d e"`, then the pattern is now counted as having _eight_ steps. This is because 'b' and 'c' are each counted as single steps, and the events in the pattenr are twice as long, and so counted as two steps each.
## Pacing the steps
Some stepwise functions don't appear to do very much on their own, for example these two examples of the `expand` function sound exactly the same despite being expanded by different amounts:
<MiniRepl client:idle tune={`"c a f e".expand(2).note().sound("folkharp")`} />
<MiniRepl client:idle tune={`"c a f e".expand(4).note().sound("folkharp")`} />
The number of steps per cycle is being changed behind the scenes, but on its own, that doesn't do anything. You will hear a difference however, once you use another stepwise function with it, for example `stepcat`:
<MiniRepl
client:idle
tune={`stepcat("c a f e".expand(2), "g d").note()
.sound("folkharp")`}
/>
<MiniRepl
client:idle
tune={`stepcat("c a f e".expand(4), "g d").note()
.sound("folkharp")`}
/>
You should be able to hear that `expand` increases the duration of the steps of the first subpattern, proportionally to the second one.
You can also change the speed of a pattern to match a given number of steps per cycle, with the `pace` function:
<MiniRepl
client:idle
tune={`stepcat("c a f e".expand(2), "g d").note()
.sound("folkharp")
.pace(8)`}
/>
<MiniRepl
client:idle
tune={`stepcat("c a f e".expand(4), "g d").note()
.sound("folkharp")
.pace(8)`}
/>
The first example has ten steps, and the second example has 18 steps, but are then both played a rate of 8 steps per cycle.
The argument to `expand` can also be patterned, and will be treated in a stepwise fashion. This means that the patterns from the changing values in the argument will be `stepcat`ted together:
<MiniRepl client:idle tune={`note("c a f e").sound("folkharp").expand("3 2 1 1 2 3")`} />
This results in a dense pattern, because the different expanded versions are squashed into a single cycle. `pace` is again handy here for slowing down the pattern to a particular number of steps per cycle:
<MiniRepl client:idle tune={`note("c a f e").sound("folkharp").expand("3 2 1 1 2 3").pace(8)`} />
Earlier versions of many of these functions had `s_` prefixes, and the `pace` function was previously known as `steps`. These still exist as aliases, but may have changed behaviour and will soon be removed. Please update your patterns!
## Stepwise functions
### pace
<JsDoc client:idle name="pace" h={0} />
### stepcat
<JsDoc client:idle name="stepcat" h={0} />
### stepalt
<JsDoc client:idle name="stepalt" h={0} />
### expand
<JsDoc client:idle name="expand" h={0} />
### contract
<JsDoc client:idle name="contract" h={0} />
### extend
<JsDoc client:idle name="extend" h={0} />
### take
<JsDoc client:idle name="take" h={0} />
### drop
<JsDoc client:idle name="drop" h={0} />
### polymeter
<JsDoc client:idle name="polymeter" h={0} />
### shrink
<JsDoc client:idle name="shrink" h={0} />
### grow
<JsDoc client:idle name="grow" h={0} />
### tour
<JsDoc client:idle name="tour" h={0} />
### zip
<JsDoc client:idle name="zip" h={0} />
+3 -1
View File
@@ -9,10 +9,12 @@ import UdelsEditor from '@components/Udels/UdelsEditor';
import ReplEditor from './components/ReplEditor';
import EmbeddedReplEditor from './components/EmbeddedReplEditor';
import { useReplContext } from './useReplContext';
import { useSettings } from '@src/settings.mjs';
export function Repl({ embedded = false }) {
const isEmbedded = embedded || isIframe();
const Editor = isUdels() ? UdelsEditor : isEmbedded ? EmbeddedReplEditor : ReplEditor;
const context = useReplContext();
return <Editor context={context} />;
const { fontFamily } = useSettings();
return <Editor context={context} style={{ fontFamily }} />;
}
@@ -9,10 +9,10 @@ import { Header } from './Header';
// }
export default function EmbeddedReplEditor(Props) {
const { context } = Props;
const { context, ...editorProps } = Props;
const { pending, started, handleTogglePlay, containerRef, editorRef, error, init } = context;
return (
<div className="h-full flex flex-col relative">
<div className="h-full flex flex-col relative" {...editorProps}>
<Loader active={pending} />
<Header context={context} embedded={true} />
<BigPlayButton started={started} handleTogglePlay={handleTogglePlay} />
+3 -2
View File
@@ -11,7 +11,7 @@ export function Header({ context, embedded = false }) {
const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShuffle, handleShare } =
context;
const isEmbedded = typeof window !== 'undefined' && (embedded || window.location !== window.parent.location);
const { isZen, isButtonRowHidden, isCSSAnimationDisabled } = useSettings();
const { isZen, isButtonRowHidden, isCSSAnimationDisabled, fontFamily } = useSettings();
return (
<header
@@ -22,6 +22,7 @@ export function Header({ context, embedded = false }) {
isZen ? 'h-12 w-8 fixed top-0 left-0' : 'sticky top-0 w-full py-1 justify-between',
isEmbedded ? 'flex' : 'md:flex',
)}
style={{ fontFamily }}
>
<div className="px-4 flex space-x-2 md:pt-0 select-none">
<h1
@@ -46,7 +47,7 @@ export function Header({ context, embedded = false }) {
}
}}
>
<span className="block rotate-90"></span>
<span className="block text-foreground rotate-90"></span>
</div>
{!isZen && (
<div className="space-x-2">
+2 -2
View File
@@ -10,13 +10,13 @@ import { useSettings } from '@src/settings.mjs';
// }
export default function ReplEditor(Props) {
const { context } = Props;
const { context, ...editorProps } = Props;
const { containerRef, editorRef, error, init, pending } = context;
const settings = useSettings();
const { panelPosition, isZen } = settings;
return (
<div className="h-full flex flex-col relative">
<div className="h-full flex flex-col relative" {...editorProps}>
<Loader active={pending} />
<Header context={context} />
<div className="grow flex relative overflow-hidden">
@@ -0,0 +1,67 @@
import { Textbox } from '../textbox/Textbox';
import cx from '@src/cx.mjs';
function IncButton({ children, className, ...buttonProps }) {
return (
<button
tabIndex={-1}
className={cx(
'border border-transparent p-1 text-center hover:text-background text-sm transition-all hover:bg-foreground active:bg-lineBackground disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none',
className,
)}
type="button"
{...buttonProps}
>
{children}
</button>
);
}
export function Incrementor({
onChange,
value,
min = -Infinity,
max = Infinity,
className,
incrementLabel = 'next page',
decrementLabel = 'prev page',
...incrementorProps
}) {
value = parseInt(value);
value = isNaN(value) ? '' : value;
return (
<div className={cx('w-fit bg-background relative flex items-center"> rounded-md', className)}>
<Textbox
min={min}
max={max}
onChange={(v) => {
if (v.length && v < min) {
return;
}
onChange(v);
}}
type="number"
placeholder=""
value={value}
className="w-32 mb-0 mt-0 border-none rounded-r-none bg-transparent appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
{...incrementorProps}
/>
<div className="flex gap-1 ">
<IncButton disabled={value <= min} onClick={() => onChange(value - 1)} aria-label={decrementLabel}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="w-4 h-4">
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</IncButton>
<IncButton
className="rounded-r-md"
disabled={value >= max}
onClick={() => onChange(value + 1)}
aria-label={incrementLabel}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="w-4 h-4">
<path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
</svg>
</IncButton>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import { Incrementor } from '../incrementor/Incrementor';
export function Pagination({ currPage, onPageChange, className, ...incrementorProps }) {
return <Incrementor min={1} value={currPage} onChange={onPageChange} className={className} {...incrementorProps} />;
}
@@ -1,53 +1,32 @@
import { logger } from '@strudel/core';
import useEvent from '@src/useEvent.mjs';
import cx from '@src/cx.mjs';
import { nanoid } from 'nanoid';
import { useCallback, useState } from 'react';
import { useSettings } from '../../../settings.mjs';
import { useStore } from '@nanostores/react';
import { $strudel_log_history } from '../useLogger';
export function ConsoleTab() {
const [log, setLog] = useState([]);
const { fontFamily, fontSize } = useSettings();
useLogger(
useCallback((e) => {
const { message, type, data } = e.detail;
setLog((l) => {
const lastLog = l.length ? l[l.length - 1] : undefined;
const id = nanoid(12);
// if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) {
if (type === 'loaded-sample') {
// const loadIndex = l.length - 1;
const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
l[loadIndex] = { message, type, id, data };
} else if (lastLog && lastLog.message === message) {
l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]);
} else {
l = l.concat([{ message, type, id, data }]);
}
return l.slice(-20);
});
}, []),
);
const log = useStore($strudel_log_history);
const { fontFamily } = useSettings();
return (
<div
id="console-tab"
className="break-all px-4 dark:text-white text-stone-900 text-sm py-2 space-y-1"
style={{ fontFamily, fontSize }}
>
{log.map((l, i) => {
const message = linkify(l.message);
const color = l.data?.hap?.value?.color;
return (
<div
key={l.id}
className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'underline')}
style={color ? { color } : {}}
>
<span dangerouslySetInnerHTML={{ __html: message }} />
{l.count ? ` (${l.count})` : ''}
</div>
);
})}
<div id="console-tab" className="break-all w-full first-line:text-sm p-2 h-full" style={{ fontFamily }}>
<div className="bg-background h-full w-full overflow-auto space-y-1 p-2 rounded-md">
{log.map((l, i) => {
const message = linkify(l.message);
const color = l.data?.hap?.value?.color;
return (
<div
key={l.id}
className={cx(
l.type === 'error' ? 'text-background bg-foreground' : 'text-foreground',
l.type === 'highlight' && 'underline',
)}
style={color ? { color } : {}}
>
<span dangerouslySetInnerHTML={{ __html: message }} />
{l.count ? ` (${l.count})` : ''}
</div>
);
})}
</div>
</div>
);
}
@@ -72,7 +51,3 @@ function linkify(inputText) {
return replacedText;
}
function useLogger(onTrigger) {
useEvent(logger.key, onTrigger);
}
@@ -5,6 +5,7 @@ import { FilesTab } from './FilesTab';
import { Reference } from './Reference';
import { SettingsTab } from './SettingsTab';
import { SoundsTab } from './SoundsTab';
import { useLogger } from '../useLogger';
import { WelcomeTab } from './WelcomeTab';
import { PatternsTab } from './PatternsTab';
import { ChevronLeftIcon, XMarkIcon } from '@heroicons/react/16/solid';
@@ -115,6 +116,7 @@ function PanelNav({ children, className, settings, ...props }) {
}
function PanelContent({ context, tab }) {
useLogger();
switch (tab) {
case tabNames.patterns:
return <PatternsTab context={context} />;
+173 -96
View File
@@ -1,6 +1,8 @@
import {
exportPatterns,
importPatterns,
loadAndSetFeaturedPatterns,
loadAndSetPublicPatterns,
patternFilterName,
useActivePattern,
useViewingPatternData,
@@ -12,10 +14,10 @@ import { useExamplePatterns } from '../../useExamplePatterns.jsx';
import { parseJSON, isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx';
import { settingsMap, useSettings } from '../../../settings.mjs';
function classNames(...classes) {
return classes.filter(Boolean).join(' ');
}
import { Pagination } from '../pagination/Pagination.jsx';
import { useState } from 'react';
import { useDebounce } from '../usedebounce.jsx';
import cx from '@src/cx.mjs';
export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) {
const meta = useMemo(() => getMetadata(pattern.code), [pattern]);
@@ -25,21 +27,19 @@ export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) {
const date = new Date(pattern.created_at);
if (!isNaN(date)) {
title = date.toLocaleDateString();
} else {
title = 'unnamed';
}
}
if (title == null) {
title = pattern.hash;
}
if (title == null) {
title = 'unnamed';
}
return <>{`${pattern.id}: ${title} by ${Array.isArray(meta.by) ? meta.by.join(',') : 'Anonymous'}`}</>;
const author = Array.isArray(meta.by) ? meta.by.join(',') : 'Anonymous';
return <>{`${pattern.id}: ${title} by ${author.slice(0, 100)}`.slice(0, 60)}</>;
}
function PatternButton({ showOutline, onClick, pattern, showHiglight }) {
return (
<a
className={classNames(
className={cx(
'mr-4 hover:opacity-50 cursor-pointer block',
showOutline && 'outline outline-1',
showHiglight && 'bg-selection',
@@ -56,7 +56,7 @@ function PatternButtons({ patterns, activePattern, onClick, started }) {
const viewingPatternData = parseJSON(viewingPatternStore);
const viewingPatternID = viewingPatternData.id;
return (
<div className="font-mono text-sm">
<div className="">
{Object.values(patterns)
.reverse()
.map((pattern) => {
@@ -84,82 +84,72 @@ function ActionButton({ children, onClick, label, labelIsHidden }) {
);
}
export function PatternsTab({ context }) {
const updateCodeWindow = (context, patternData, reset = false) => {
context.handleUpdate(patternData, reset);
};
const autoResetPatternOnChange = !isUdels();
function UserPatterns({ context }) {
const activePattern = useActivePattern();
const viewingPatternStore = useViewingPatternData();
const viewingPatternData = parseJSON(viewingPatternStore);
const { userPatterns, patternFilter } = useSettings();
const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections;
const updateCodeWindow = (patternData, reset = false) => {
context.handleUpdate(patternData, reset);
};
const viewingPatternID = viewingPatternData?.id;
const autoResetPatternOnChange = !isUdels();
return (
<div className="px-4 w-full dark:text-white text-stone-900 space-y-2 flex flex-col overflow-hidden max-h-full h-full">
<ButtonGroup
value={patternFilter}
onChange={(value) => settingsMap.setKey('patternFilter', value)}
items={patternFilterName}
></ButtonGroup>
{patternFilter === patternFilterName.user && (
<div>
<div className="pr-4 space-x-4 border-b border-foreground flex max-w-full overflow-x-auto">
<ActionButton
label="new"
onClick={() => {
const { data } = userPattern.createAndAddToDB();
updateCodeWindow(data);
}}
/>
<ActionButton
label="duplicate"
onClick={() => {
const { data } = userPattern.duplicate(viewingPatternData);
updateCodeWindow(data);
}}
/>
<ActionButton
label="delete"
onClick={() => {
const { data } = userPattern.delete(viewingPatternID);
updateCodeWindow({ ...data, collection: userPattern.collection });
}}
/>
<label className="hover:opacity-50 cursor-pointer">
<input
style={{ display: 'none' }}
type="file"
multiple
accept="text/plain,application/json"
onChange={(e) => importPatterns(e.target.files)}
/>
import
</label>
<ActionButton label="export" onClick={exportPatterns} />
<div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 ">
<div className="pr-4 space-x-4 flex max-w-full overflow-x-auto">
<ActionButton
label="new"
onClick={() => {
const { data } = userPattern.createAndAddToDB();
updateCodeWindow(context, data);
}}
/>
<ActionButton
label="duplicate"
onClick={() => {
const { data } = userPattern.duplicate(viewingPatternData);
updateCodeWindow(context, data);
}}
/>
<ActionButton
label="delete"
onClick={() => {
const { data } = userPattern.delete(viewingPatternID);
updateCodeWindow(context, { ...data, collection: userPattern.collection });
}}
/>
<label className="hover:opacity-50 cursor-pointer">
<input
style={{ display: 'none' }}
type="file"
multiple
accept="text/plain,application/json"
onChange={(e) => importPatterns(e.target.files)}
/>
import
</label>
<ActionButton label="export" onClick={exportPatterns} />
<ActionButton
label="delete-all"
onClick={() => {
const { data } = userPattern.clearAll();
updateCodeWindow(data);
}}
/>
</div>
</div>
)}
<ActionButton
label="delete-all"
onClick={() => {
const { data } = userPattern.clearAll();
updateCodeWindow(context, data);
}}
/>
</div>
<section className="flex overflow-y-auto max-h-full flex-grow flex-col">
<div className="overflow-auto h-full bg-background p-2 rounded-md">
{patternFilter === patternFilterName.user && (
<PatternButtons
onClick={(id) =>
updateCodeWindow({ ...userPatterns[id], collection: userPattern.collection }, autoResetPatternOnChange)
updateCodeWindow(
context,
{ ...userPatterns[id], collection: userPattern.collection },
autoResetPatternOnChange,
)
}
patterns={userPatterns}
started={context.started}
@@ -167,24 +157,111 @@ export function PatternsTab({ context }) {
viewingPatternID={viewingPatternID}
/>
)}
{patternFilter !== patternFilterName.user &&
Array.from(collections.keys()).map((collection) => {
const patterns = collections.get(collection);
return (
<section key={collection} className="py-2">
<h2 className="text-xl mb-2">{collection}</h2>
<div className="font-mono text-sm">
<PatternButtons
onClick={(id) => updateCodeWindow({ ...patterns[id], collection }, autoResetPatternOnChange)}
started={context.started}
patterns={patterns}
activePattern={activePattern}
/>
</div>
</section>
);
})}
</section>
</div>
</div>
);
}
function PatternPageWithPagination({ patterns, patternOnClick, context, paginationOnChange, initialPage }) {
const [page, setPage] = useState(initialPage);
const debouncedPageChange = useDebounce(() => {
paginationOnChange(page);
});
const onPageChange = (pageNum) => {
setPage(pageNum);
debouncedPageChange();
};
const activePattern = useActivePattern();
return (
<div className="flex flex-grow flex-col h-full overflow-hidden justify-between">
<div className="overflow-auto flex flex-col flex-grow bg-background p-2 rounded-md ">
<PatternButtons
onClick={(id) => patternOnClick(id)}
started={context.started}
patterns={patterns}
activePattern={activePattern}
/>
</div>
<div className="flex items-center gap-2 py-2">
<label htmlFor="pattern pagination">Page</label>
<Pagination id="pattern pagination" currPage={page} onPageChange={onPageChange} />
</div>
</div>
);
}
let featuredPageNum = 1;
function FeaturedPatterns({ context }) {
const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.featured);
return (
<PatternPageWithPagination
patterns={patterns}
context={context}
initialPage={featuredPageNum}
patternOnClick={(id) => {
updateCodeWindow(
context,
{ ...patterns[id], collection: patternFilterName.featured },
autoResetPatternOnChange,
);
}}
paginationOnChange={async (pageNum) => {
await loadAndSetFeaturedPatterns(pageNum - 1);
featuredPageNum = pageNum;
}}
/>
);
}
let latestPageNum = 1;
function LatestPatterns({ context }) {
const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.public);
return (
<PatternPageWithPagination
patterns={patterns}
context={context}
initialPage={latestPageNum}
patternOnClick={(id) => {
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, autoResetPatternOnChange);
}}
paginationOnChange={async (pageNum) => {
await loadAndSetPublicPatterns(pageNum - 1);
latestPageNum = pageNum;
}}
/>
);
}
function PublicPatterns({ context }) {
const { patternFilter } = useSettings();
if (patternFilter === patternFilterName.featured) {
return <FeaturedPatterns context={context} />;
}
return <LatestPatterns context={context} />;
}
export function PatternsTab({ context }) {
const { patternFilter } = useSettings();
return (
<div className="px-4 w-full text-foreground space-y-2 flex flex-col overflow-hidden max-h-full h-full">
<ButtonGroup
value={patternFilter}
onChange={(value) => settingsMap.setKey('patternFilter', value)}
items={patternFilterName}
></ButtonGroup>
{patternFilter === patternFilterName.user ? (
<UserPatterns context={context} />
) : (
<PublicPatterns context={context} />
)}
</div>
);
}
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import jsdocJson from '../../../../../doc.json';
import { Textbox } from '../textbox/Textbox';
const availableFunctions = jsdocJson.docs
.filter(({ name, description }) => name && !name.startsWith('_') && !!description)
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
@@ -25,21 +26,16 @@ export function Reference() {
}, [search]);
return (
<div className="flex h-full w-full p-2 text-foreground overflow-hidden">
<div className="flex h-full w-full p-2 overflow-hidden">
<div className="h-full flex flex-col gap-2 w-1/3 max-w-72 ">
<div class="w-full flex">
<input
className="w-full p-1 bg-background rounded-md border-none"
placeholder="Search"
value={search}
onInput={(event) => setSearch(event.target.value)}
/>
<Textbox className="w-full" placeholder="Search" value={search} onChange={setSearch} />
</div>
<div className="flex flex-col h-full overflow-y-auto gap-1.5 bg-background bg-opacity-50 rounded-md">
{visibleFunctions.map((entry, i) => (
<a
key={i}
className="cursor-pointer flex-none hover:bg-lineHighlight overflow-x-hidden px-1 text-ellipsis"
className="cursor-pointer text-foreground flex-none hover:bg-lineHighlight overflow-x-hidden px-1 text-ellipsis"
onClick={() => {
const el = document.getElementById(`doc-${i}`);
const container = document.getElementById('reference-container');
@@ -79,7 +75,9 @@ export function Reference() {
))}
</ul>
{entry.examples?.map((example, j) => (
<pre key={j}>{example}</pre>
<pre className="bg-background" key={j}>
{example}
</pre>
))}
</section>
))}
@@ -66,6 +66,7 @@ const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k]));
const fontFamilyOptions = {
monospace: 'monospace',
Courier: 'Courier',
CutiePi: 'CutiePi',
JetBrains: 'JetBrains',
Hack: 'Hack',
FiraCode: 'FiraCode',
@@ -108,7 +109,7 @@ export function SettingsTab({ started }) {
const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
return (
<div className="text-foreground p-4 space-y-4 w-full">
<div className="text-foreground p-4 space-y-4 w-full" style={{ fontFamily }}>
{canChangeAudioDevice && (
<FormItem label="Audio Output Device">
<AudioDeviceSelector
@@ -141,7 +142,7 @@ export function SettingsTab({ started }) {
<FormItem label="Theme">
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
</FormItem>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 font-sans">
<FormItem label="Font Family">
<SelectInput
options={fontFamilyOptions}
@@ -5,6 +5,7 @@ import { useMemo, useRef, useState } from 'react';
import { settingsMap, useSettings } from '../../../settings.mjs';
import { ButtonGroup } from './Forms.jsx';
import ImportSoundsButton from './ImportSoundsButton.jsx';
import { Textbox } from '../textbox/Textbox.jsx';
const getSamples = (samples) =>
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
@@ -52,13 +53,8 @@ export function SoundsTab() {
});
return (
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full dark:text-white text-stone-900">
<input
className="w-full p-1 bg-background rounded-md my-2"
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full text-foreground">
<Textbox placeholder="Search" value={search} onChange={(v) => setSearch(v)} />
<div className="pb-2 flex shrink-0 flex-wrap">
<ButtonGroup
@@ -74,7 +70,7 @@ export function SoundsTab() {
<ImportSoundsButton onComplete={() => settingsMap.setKey('soundsFilter', 'user')} />
</div>
<div className="min-h-0 max-h-full grow overflow-auto font-mono text-sm break-normal pb-2">
<div className="min-h-0 max-h-full grow overflow-auto text-sm break-normal pb-2">
{soundEntries.map(([name, { data, onTrigger }]) => {
return (
<span
@@ -1,11 +1,12 @@
import cx from '@src/cx.mjs';
import { useSettings } from '@src/settings.mjs';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
export function WelcomeTab({ context }) {
const { fontFamily } = useSettings();
return (
<div className="prose dark:prose-invert min-w-full pt-2 font-sans pb-8 px-4 ">
<div className="prose dark:prose-invert min-w-full pt-2 font-sans pb-8 px-4 " style={{ fontFamily }}>
<h3> welcome</h3>
<p>
You have found <span className="underline">strudel</span>, a new live coding platform to write dynamic music
@@ -43,7 +44,8 @@ export function WelcomeTab({ context }) {
<a href="https://github.com/tidalcycles/strudel" target="_blank">
github
</a>
. Please consider to{' '}
. You can also find <a href="https://github.com/felixroos/dough-samples/blob/main/README.md">licensing info</a>{' '}
for the default sound banks there. Please consider to{' '}
<a href="https://opencollective.com/tidalcycles" target="_blank">
support this project
</a>{' '}
@@ -0,0 +1,11 @@
import cx from '@src/cx.mjs';
export function Textbox({ onChange, className, ...inputProps }) {
return (
<input
className={cx('p-1 bg-background rounded-md my-2 border-foreground', className)}
onChange={(e) => onChange(e.target.value)}
{...inputProps}
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
import useEvent from '@src/useEvent.mjs';
import { logger } from '@strudel/core';
import { nanoid } from 'nanoid';
import { atom } from 'nanostores';
export const $strudel_log_history = atom([]);
function useLoggerEvent(onTrigger) {
useEvent(logger.key, onTrigger);
}
function getUpdatedLog(log, event) {
const { message, type, data } = event.detail;
const lastLog = log.length ? log[log.length - 1] : undefined;
const id = nanoid(12);
if (type === 'loaded-sample') {
const loadIndex = log.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
log[loadIndex] = { message, type, id, data };
} else if (lastLog && lastLog.message === message) {
log = log.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]);
} else {
log = log.concat([{ message, type, id, data }]);
}
return log.slice(-20);
}
export function useLogger() {
useLoggerEvent((event) => {
const log = $strudel_log_history.get();
const newLog = getUpdatedLog(log, event);
$strudel_log_history.set(newLog);
});
}
@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import { useEffect } from 'react';
import { useRef } from 'react';
function debounce(fn, wait) {
let timer;
return function (...args) {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => fn(...args), wait);
};
}
export function useDebounce(callback) {
const ref = useRef;
useEffect(() => {
ref.current = callback;
}, [callback]);
const debouncedCallback = useMemo(() => {
const func = () => {
ref.current?.();
};
return debounce(func, 1000);
}, []);
return debouncedCallback;
}
+27 -1
View File
@@ -1,5 +1,5 @@
import { Pattern, noteToMidi, valueToMidi } from '@strudel/core';
import { registerSynthSounds, registerZZFXSounds, samples } from '@strudel/webaudio';
import { aliasBank, registerSynthSounds, registerZZFXSounds, samples } from '@strudel/webaudio';
import { registerSamplesFromDB } from './idbutils.mjs';
import './piano.mjs';
import './files.mjs';
@@ -29,6 +29,7 @@ export async function prebake() {
tag: 'drum-machines',
}),
samples(`${baseNoTrailing}/EmuSP12.json`, undefined, { prebake: true, tag: 'drum-machines' }),
samples(`${baseNoTrailing}/mridangam.json`, undefined, { prebake: true, tag: 'drum-machines' }),
samples(
{
casio: ['casio/high.wav', 'casio/low.wav', 'casio/noise.wav'],
@@ -114,6 +115,29 @@ export async function prebake() {
'numbers/7.wav',
'numbers/8.wav',
],
num: [
'num/00.wav',
'num/01.wav',
'num/02.wav',
'num/03.wav',
'num/04.wav',
'num/05.wav',
'num/06.wav',
'num/07.wav',
'num/08.wav',
'num/09.wav',
'num/10.wav',
'num/11.wav',
'num/12.wav',
'num/13.wav',
'num/14.wav',
'num/15.wav',
'num/16.wav',
'num/17.wav',
'num/18.wav',
'num/19.wav',
'num/20.wav',
],
},
'github:tidalcycles/dirt-samples',
{
@@ -121,6 +145,8 @@ export async function prebake() {
},
),
]);
aliasBank(`${baseNoTrailing}/tidal-drum-machines-alias.json`);
}
const maxPan = noteToMidi('C8');
+4 -4
View File
@@ -1,4 +1,4 @@
import { $featuredPatterns, $publicPatterns, collectionName } from '../user_pattern_utils.mjs';
import { $featuredPatterns, $publicPatterns, patternFilterName } from '../user_pattern_utils.mjs';
import { useStore } from '@nanostores/react';
import { useMemo } from 'react';
import * as tunes from '../repl/tunes.mjs';
@@ -12,9 +12,9 @@ export const useExamplePatterns = () => {
const publicPatterns = useStore($publicPatterns);
const collections = useMemo(() => {
const pats = new Map();
pats.set(collectionName.featured, featuredPatterns);
pats.set(collectionName.public, publicPatterns);
// pats.set(collectionName.stock, stockPatterns);
pats.set(patternFilterName.featured, featuredPatterns);
pats.set(patternFilterName.public, publicPatterns);
// pats.set(patternFilterName.stock, stockPatterns);
return pats;
}, [featuredPatterns, publicPatterns]);
+1
View File
@@ -81,6 +81,7 @@ export function loadModules() {
import('@strudel/soundfonts'),
import('@strudel/csound'),
import('@strudel/tidal'),
import('@strudel/gamepad'),
import('@strudel/motion'),
import('@strudel/mqtt'),
];
+9
View File
@@ -1,6 +1,7 @@
@font-face {
font-family: 'PressStart';
src: url('/fonts/PressStart2P/PressStart2P-Regular.ttf');
size-adjust: 65%;
}
@font-face {
font-family: 'BigBlueTerminal';
@@ -14,6 +15,11 @@
font-family: 'galactico';
src: url('/fonts/galactico/Galactico-Basic.otf');
}
@font-face {
font-family: 'CutiePi';
src: url('/fonts/CutiePi/Cute_Aurora_demo.ttf');
size-adjust: 120%;
}
@font-face {
font-family: 'JetBrains';
src: url('/fonts/JetBrains/JetBrainsMono.woff2');
@@ -21,6 +27,7 @@
@font-face {
font-family: 'Monocraft';
src: url('/fonts/Monocraft/Monocraft.ttf');
size-adjust: 90%;
}
@font-face {
font-family: 'Hack';
@@ -41,10 +48,12 @@
@font-face {
font-family: 'teletext';
src: url('/fonts/teletext/EuropeanTeletext.ttf');
size-adjust: 90%;
}
@font-face {
font-family: 'mode7';
src: url('/fonts/mode7/MODE7GX3.TTF');
size-adjust: 82%;
}
.prose > h1:not(:first-child) {
+45 -24
View File
@@ -8,16 +8,12 @@ import { confirmDialog, parseJSON, supabase } from './repl/util.mjs';
export let $publicPatterns = atom([]);
export let $featuredPatterns = atom([]);
export const collectionName = {
user: 'user',
public: 'Last Creations',
stock: 'Stock Examples',
featured: 'Featured',
};
const patternQueryLimit = 20;
export const patternFilterName = {
community: 'community',
public: 'latest',
featured: 'featured',
user: 'user',
// stock: 'stock examples',
};
const sessionAtom = (name, initial = undefined) => {
@@ -36,7 +32,7 @@ const sessionAtom = (name, initial = undefined) => {
export let $viewingPatternData = sessionAtom('viewingPatternData', {
id: '',
code: '',
collection: collectionName.user,
collection: patternFilterName.user,
created_at: Date.now(),
});
@@ -51,25 +47,50 @@ export const setViewingPatternData = (data) => {
$viewingPatternData.set(JSON.stringify(data));
};
export function loadPublicPatterns() {
return supabase.from('code_v1').select().eq('public', true).limit(20).order('id', { ascending: false });
function parsePageNum(page) {
return isNaN(page) ? 0 : page;
}
export function loadPublicPatterns(page) {
page = parsePageNum(page);
const offset = page * patternQueryLimit;
return supabase
.from('code_v1')
.select()
.eq('public', true)
.range(offset, offset + patternQueryLimit)
.order('id', { ascending: false });
}
export function loadFeaturedPatterns() {
return supabase.from('code_v1').select().eq('featured', true).limit(20).order('id', { ascending: false });
export function loadFeaturedPatterns(page = 0) {
page = parsePageNum(page);
const offset = page * patternQueryLimit;
return supabase
.from('code_v1')
.select()
.eq('featured', true)
.range(offset, offset + patternQueryLimit)
.order('id', { ascending: false });
}
export async function loadAndSetPublicPatterns(page) {
const p = await loadPublicPatterns(page);
const data = p?.data;
const pats = {};
data?.forEach((data, key) => (pats[data.id ?? key] = data));
$publicPatterns.set(pats);
}
export async function loadAndSetFeaturedPatterns(page) {
const p = await loadFeaturedPatterns(page);
const data = p?.data;
const pats = {};
data?.forEach((data, key) => (pats[data.id ?? key] = data));
$featuredPatterns.set(pats);
}
export async function loadDBPatterns() {
try {
const { data: publicPatterns } = await loadPublicPatterns();
const { data: featuredPatterns } = await loadFeaturedPatterns();
const featured = {};
const pub = {};
publicPatterns?.forEach((data, key) => (pub[data.id ?? key] = data));
featuredPatterns?.forEach((data, key) => (featured[data.id ?? key] = data));
$publicPatterns.set(pub);
$featuredPatterns.set(featured);
await loadAndSetPublicPatterns();
await loadAndSetFeaturedPatterns();
} catch (err) {
console.error('error loading patterns', err);
}
@@ -90,9 +111,9 @@ export function useActivePattern() {
export const setLatestCode = (code) => settingsMap.setKey('latestCode', code);
const defaultCode = '';
export const defaultCode = '';
export const userPattern = {
collection: collectionName.user,
collection: patternFilterName.user,
getAll() {
const patterns = parseJSON(settingsMap.get().userPatterns);
return patterns ?? {};
+23
View File
@@ -46,6 +46,29 @@ module.exports = {
'code::after': {
content: 'none',
},
color: 'var(--foreground)',
a: {
color: 'var(--foreground)',
},
h1: {
color: 'var(--foreground)',
},
h2: {
color: 'var(--foreground)',
},
h3: {
color: 'var(--foreground)',
},
h4: {
color: 'var(--foreground)',
},
pre: {
color: 'var(--foreground)',
background: 'var(--background)',
},
code: {
color: 'var(--foreground)',
},
},
},
};