diff --git a/packages/gamepad/README.md b/packages/gamepad/README.md
new file mode 100644
index 00000000..239353fb
--- /dev/null
+++ b/packages/gamepad/README.md
@@ -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
+```
diff --git a/packages/gamepad/docs/gamepad.mdx b/packages/gamepad/docs/gamepad.mdx
new file mode 100644
index 00000000..219d8ae0
--- /dev/null
+++ b/packages/gamepad/docs/gamepad.mdx
@@ -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.
+
+
+
+## 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.
+
+
+
+### Analog Stick Inputs
+
+Analog sticks can be used for continuous control, such as pitch shifting or panning.
+
+
+
+### Button Sequences
+
+You can define button sequences to trigger specific actions, like playing a sound when a sequence is detected.
+
+
+
+## Multiple Gamepads
+
+Strudel supports multiple gamepads. You can specify the gamepad index to connect to different devices.
+
+
diff --git a/packages/gamepad/gamepad.mjs b/packages/gamepad/gamepad.mjs
new file mode 100644
index 00000000..7667a362
--- /dev/null
+++ b/packages/gamepad/gamepad.mjs
@@ -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();
diff --git a/packages/gamepad/index.mjs b/packages/gamepad/index.mjs
new file mode 100644
index 00000000..c5558fb9
--- /dev/null
+++ b/packages/gamepad/index.mjs
@@ -0,0 +1,3 @@
+import './gamepad.mjs';
+
+export * from './gamepad.mjs';
diff --git a/packages/gamepad/package.json b/packages/gamepad/package.json
new file mode 100644
index 00000000..199c5d03
--- /dev/null
+++ b/packages/gamepad/package.json
@@ -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 ",
+ "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"
+ }
+}
+
\ No newline at end of file
diff --git a/packages/gamepad/vite.config.js b/packages/gamepad/vite.config.js
new file mode 100644
index 00000000..5df3edc1
--- /dev/null
+++ b/packages/gamepad/vite.config.js
@@ -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',
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2cdd501c..32fd711e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -270,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:
@@ -643,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
diff --git a/test/runtime.mjs b/test/runtime.mjs
index b0a329a4..c35810da 100644
--- a/test/runtime.mjs
+++ b/test/runtime.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() {
@@ -131,6 +134,7 @@ evalScope(
uiHelpersMocked,
webaudio,
tonalHelpers,
+ gamepadHelpers,
/*
toneHelpers,
voicingHelpers,
diff --git a/website/package.json b/website/package.json
index 411581f8..257ff643 100644
--- a/website/package.json
+++ b/website/package.json
@@ -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:*",
diff --git a/website/src/config.ts b/website/src/config.ts
index 376db54f..2f499d55 100644
--- a/website/src/config.ts
+++ b/website/src/config.ts
@@ -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': [
diff --git a/website/src/pages/learn/devicemotion.mdx b/website/src/pages/learn/devicemotion.mdx
index cd54a7cf..a41b4b97 100644
--- a/website/src/pages/learn/devicemotion.mdx
+++ b/website/src/pages/learn/devicemotion.mdx
@@ -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';
diff --git a/website/src/pages/learn/input-devices.mdx b/website/src/pages/learn/input-devices.mdx
new file mode 100644
index 00000000..760ad719
--- /dev/null
+++ b/website/src/pages/learn/input-devices.mdx
@@ -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.
+
+
diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs
index f623e468..a8d18428 100644
--- a/website/src/repl/util.mjs
+++ b/website/src/repl/util.mjs
@@ -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'),
];