Merge branch 'main' into add-program-change

This commit is contained in:
Felix Roos 2025-03-08 22:08:03 +01:00
commit 09dd374722
No known key found for this signature in database
53 changed files with 1763 additions and 395 deletions

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,

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
packages/codemirror/themes/CutiePi.mjs vendored Normal file
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 },
],
});

View file

@ -388,7 +388,7 @@ export class Pattern {
polyJoin = function () {
const pp = this;
return pp.fmap((p) => p.repeat(pp._steps.div(p._steps))).outerJoin();
return pp.fmap((p) => p.extend(pp._steps.div(p._steps))).outerJoin();
};
polyBind(func) {
@ -1244,6 +1244,16 @@ export function reify(thing) {
return pure(thing);
}
/** Takes a list of patterns, and returns a pattern of lists.
*/
export function sequenceP(pats) {
let result = pure([]);
for (const pat of pats) {
result = result.bind((list) => pat.fmap((v) => list.concat([v])));
}
return result;
}
/** The given items are played at the same time at the same length.
*
* @return {Pattern}
@ -1318,7 +1328,7 @@ export function stackBy(by, ...pats) {
left: stackLeft,
right: stackRight,
expand: stack,
repeat: (...args) => polymeterSteps(steps, ...args),
repeat: (...args) => polymeter(...args).steps(steps),
};
return by
.inhabit(lookup)
@ -1820,7 +1830,10 @@ export const { fastGap, fastgap } = register(['fastGap', 'fastgap'], function (f
export const focus = register('focus', function (b, e, pat) {
b = Fraction(b);
e = Fraction(e);
return pat._fast(Fraction(1).div(e.sub(b))).late(b.cyclePos());
return pat
._early(b.sam())
._fast(Fraction(1).div(e.sub(b)))
._late(b);
});
export const { focusSpan, focusspan } = register(['focusSpan', 'focusspan'], function (span, pat) {
@ -2030,7 +2043,7 @@ export const zoom = register('zoom', function (s, e, pat) {
return nothing;
}
const d = e.sub(s);
const steps = __steps ? pat._steps.mulmaybe(d) : undefined;
const steps = __steps ? pat._steps?.mulmaybe(d) : undefined;
return pat
.withQuerySpan((span) => span.withCycle((t) => t.mul(d).add(s)))
.withHapSpan((span) => span.withCycle((t) => t.sub(s).div(d)))
@ -2635,34 +2648,10 @@ export function _polymeterListSteps(steps, ...args) {
/**
* *Experimental*
*
* Aligns the steps of the patterns, to match the given number of steps per cycle, creating polymeters.
*
* @name polymeterSteps
* @param {number} steps how many items are placed in one cycle
* @param {any[]} patterns one or more patterns
* @example
* // the same as "{c d, e f g}%4"
* polymeterSteps(4, "c d", "e f g").note()
*/
export function polymeterSteps(steps, ...args) {
if (args.length == 0) {
return silence;
}
if (Array.isArray(args[0])) {
// Support old behaviour
return _polymeterListSteps(steps, ...args);
}
return polymeter(...args).pace(steps);
}
/**
* *Experimental*
*
* Aligns the steps of the patterns, to match the steps per cycle of the first pattern, creating polymeters. See `polymeterSteps` to set the target steps explicitly.
* Aligns the steps of the patterns, creating polymeters. The patterns are repeated until they all fit the cycle. For example, in the below the first pattern is repeated twice, and the second is repeated three times, to fit the lowest common multiple of six steps.
* @synonyms pm
* @example
* // The same as note("{c eb g, c2 g2}")
* // The same as note("{c eb g, c2 g2}%6")
* polymeter("c eb g", "c2 g2").note()
*
*/
@ -2678,13 +2667,12 @@ export function polymeter(...args) {
if (args.length == 0) {
return silence;
}
const steps = args[0]._steps;
const steps = lcm(...args.map((x) => x._steps));
if (steps.eq(Fraction(0))) {
return nothing;
}
const [head, ...tail] = args;
const result = stack(head, ...tail.map((pat) => pat._slow(pat._steps.div(steps))));
const result = stack(...args.map((x) => x.pace(steps)));
result._steps = steps;
return result;
}
@ -2845,16 +2833,16 @@ export const drop = stepRegister('drop', function (i, pat) {
/**
* *Experimental*
*
* `repeat` is similar to `fast` in that it 'speeds up' the pattern, but it also increases the step count
* accordingly. So `stepcat("a b".repeat(2), "c d")` would be the same as `"a b a b c d"`, whereas
* `extend` is similar to `fast` in that it increases its density, but it also increases the step count
* accordingly. So `stepcat("a b".extend(2), "c d")` would be the same as `"a b a b c d"`, whereas
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
* @example
* stepcat(
* sound("bd bd - cp").repeat(2),
* sound("bd bd - cp").extend(2),
* sound("bd - sd -")
* ).pace(8)
*/
export const repeat = stepRegister('repeat', function (factor, pat) {
export const extend = stepRegister('extend', function (factor, pat) {
return pat.fast(factor).expand(factor);
});
@ -3052,8 +3040,6 @@ export const timeCat = stepcat;
// Deprecated stepwise aliases
export const s_cat = stepcat;
export const s_alt = stepalt;
export const s_polymeterSteps = polymeterSteps;
Pattern.prototype.s_polymeterSteps = Pattern.prototype.polymeterSteps;
export const s_polymeter = polymeter;
Pattern.prototype.s_polymeter = Pattern.prototype.polymeter;
export const s_taper = shrink;
@ -3066,8 +3052,8 @@ export const s_sub = drop;
Pattern.prototype.s_sub = Pattern.prototype.drop;
export const s_expand = expand;
Pattern.prototype.s_expand = Pattern.prototype.expand;
export const s_extend = repeat;
Pattern.prototype.s_extend = Pattern.prototype.repeat;
export const s_extend = extend;
Pattern.prototype.s_extend = Pattern.prototype.extend;
export const s_contract = contract;
Pattern.prototype.s_contract = Pattern.prototype.contract;
export const s_tour = tour;

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';
@ -20,9 +20,6 @@ export const signal = (func) => {
return new Pattern(query);
};
export const isaw = signal((t) => 1 - (t % 1));
export const isaw2 = isaw.toBipolar();
/**
* A sawtooth signal between 0 and 1.
*
@ -36,8 +33,40 @@ export const isaw2 = isaw.toBipolar();
*
*/
export const saw = signal((t) => t % 1);
/**
* A sawtooth signal between -1 and 1 (like `saw`, but bipolar).
*
* @return {Pattern}
*/
export const saw2 = saw.toBipolar();
/**
* A sawtooth signal between 1 and 0 (like `saw`, but flipped).
*
* @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));
/**
@ -61,6 +90,12 @@ export const sine = sine2.fromBipolar();
*
*/
export const cosine = sine._early(Fraction(1).div(4));
/**
* A cosine signal between -1 and 1 (like `cosine`, but bipolar).
*
* @return {Pattern}
*/
export const cosine2 = sine2._early(Fraction(1).div(4));
/**
@ -72,6 +107,12 @@ export const cosine2 = sine2._early(Fraction(1).div(4));
*
*/
export const square = signal((t) => Math.floor((t * 2) % 2));
/**
* A square signal between -1 and 1 (like `square`, but bipolar).
*
* @return {Pattern}
*/
export const square2 = square.toBipolar();
/**
@ -82,9 +123,37 @@ export const square2 = square.toBipolar();
* n(tri.segment(8).range(0,7)).scale("C:minor")
*
*/
export const tri = fastcat(isaw, saw);
export const tri2 = fastcat(isaw2, saw2);
export const tri = fastcat(saw, isaw);
/**
* A triangle signal between -1 and 1 (like `tri`, but bipolar).
*
* @return {Pattern}
*/
export const tri2 = fastcat(saw2, isaw2);
/**
* An inverted triangle signal between 1 and 0 (like `tri`, but flipped).
*
* @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);
/**
@ -364,19 +433,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();
@ -398,6 +478,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();

View file

@ -22,7 +22,6 @@ import {
sequence,
palindrome,
polymeter,
polymeterSteps,
polyrhythm,
silence,
fast,
@ -611,17 +610,10 @@ describe('Pattern', () => {
});
describe('polymeter()', () => {
it('Can layer up cycles, stepwise, with lists', () => {
expect(polymeterSteps(3, ['d', 'e']).firstCycle()).toStrictEqual(
fastcat(pure('d'), pure('e'), pure('d')).firstCycle(),
);
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(polymeterSteps(3, sequence('a', 'b')).fast(2), sequence('a', 'b', 'a', 'b', 'a', 'b'));
});
});
describe('firstOf()', () => {
@ -1265,4 +1257,14 @@ describe('Pattern', () => {
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),
);
});
});
});

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
```

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`}
/>

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();

View file

@ -0,0 +1,3 @@
import './gamepad.mjs';
export * from './gamepad.mjs';

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"
}
}

View file

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

View file

@ -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,

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);

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",