Compare commits

..

8 Commits

Author SHA1 Message Date
froos b48321a84c Merge branch 'main' into nkymut/gamepad-pr 2025-10-28 23:32:28 +01:00
froos 827b659592 Merge branch 'main' into nkymut/gamepad-pr 2025-10-20 20:42:00 +02:00
nkymut 4553408b28 fix custom button mapping error 2025-05-21 01:18:44 +08:00
nkymut 42eb33440c Improve logger messages and connection error handling 2025-05-21 01:00:58 +08:00
nkymut 87768198a0 fix import logger 2025-05-17 16:10:11 +08:00
nkymut c24a87df48 codeformat 2025-05-17 16:06:06 +08:00
nkymut 2d63f36201 update gamepad documentation for button mapping 2025-05-17 16:03:57 +08:00
nkymut 43ac2d3d72 Add gamepad button mapping support 2025-05-17 15:41:48 +08:00
9 changed files with 174 additions and 95 deletions
-9
View File
@@ -72,15 +72,6 @@ export function registerControl(names, ...aliases) {
return bag;
}
// Constructs a superdirt effect bus control
const bus = register('bus', function (busid, control_name, busval, pat) {
return pat[control_name]('c' + busid).withValue((val) => {
const newval = { ...val };
newval['^' + control_name] = [busid, busval];
return newval;
});
});
/**
* Select a sound / sample by name. When using mininotation, you can also optionally supply 'n' and 'gain' parameters
* separated by ':'.
+3 -2
View File
@@ -58,10 +58,11 @@ export class Cyclist {
// query the pattern for events
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'cyclist' });
haps.forEach((hap) => {
if (hap.hasOnset() || hap.context.processParts) {
if (hap.hasOnset()) {
const targetTime =
(hap.part.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
const duration = hap.duration / this.cps;
// the following line is dumb and only here for backwards compatibility
// see https://codeberg.org/uzu/strudel/pulls/1004
+1 -5
View File
@@ -16,7 +16,7 @@ export class Hap {
then the whole will be returned as None, in which case the given
value will have been sampled from the point halfway between the
start and end of the 'part' timespan.
The context is to store metadata, including a list of source code locations causing the event.
The context is to store a list of source code locations causing the event.
The word 'Event' is more or less a reserved word in javascript, hence this
class is named called 'Hap'.
@@ -161,10 +161,6 @@ export class Hap {
return new Hap(this.whole, this.part, this.value, context);
}
withContext(f) {
return new Hap(this.whole, this.part, this.value, f(this.context));
}
ensureObjectValue() {
/* if (isNote(hap.value)) {
// supports primitive hap values that look like notes
-5
View File
@@ -62,12 +62,7 @@ export class Pattern {
this.__steps = steps === undefined ? undefined : Fraction(steps);
}
_processParts() {
return this.withHap((hap) => hap.withContext((c) => ({ ...c, processParts: true })));
}
setSteps(steps) {
// TODO shouldn't this be pure and return a new pattern?
this._steps = steps;
return this;
}
+7 -1
View File
@@ -14,7 +14,12 @@ npm i @strudel/gamepad --save
import { gamepad } from '@strudel/gamepad';
// Initialize gamepad (optional index parameter, defaults to 0)
const pad = gamepad(0);
const pad = gamepad(0); // Default mapping is XBOX {a: 0, b: 1, x: 2, y: 3}
//const pad = gamepad(0, 'XBOX'); // XBOX button mapping {a: 0, b: 1, x: 2, y: 3}
//const pad = gamepad(0, 'NES'); // Nintendo button mapping {a: 1, b: 0, x: 3, y: 2}
//const pad = gamepad(0, {a: 0, b: 1, x: 2, y: 3}); // Custom mapping
// Use gamepad inputs in patterns
const pattern = sequence([
@@ -86,6 +91,7 @@ $: sound("hadoken").gain(pad.checkSequence(HADOKEN))
## Multiple Gamepads
You can connect multiple gamepads by specifying the gamepad index:
Make sure to press buttons on all connected gamepads before hitting play, so the browser can properly detect them.
```javascript
const pad1 = gamepad(0); // First gamepad
+24 -1
View File
@@ -107,9 +107,32 @@ $: s("free_hadouken -").slow(2)
samples({free_hadouken: 'https://cdn.freesound.org/previews/67/67674_111920-lq.mp3'})
`} />
## Multiple Gamepads
### Button Sequences
### Button Mappings
The gamepad module supports different button mapping configurations to accommodate various gamepad layouts:
- **XBOX** (Default): Standard Xbox-style mapping where A=0, B=1, X=2, Y=3
- **NES**: Nintendo-style mapping where B=0, A=1, Y=2, X=3
- **Custom**: Define your own button mapping by passing an object with button assignments
You can specify the mapping when initializing the gamepad:
<MiniRepl
client:idle
tune={`
const pad1 = gamepad(0); // Default mapping is XBOX {a: 0, b: 1, x: 2, y: 3}
const pad2 = gamepad(1, 'XBOX'); // XBOX button mapping {a: 0, b: 1, x: 2, y: 3}
const pad3 = gamepad(2, 'NES'); // Nintendo button mapping {a: 1, b: 0, x: 3, y: 2}
const pad4 = gamepad(3, {a: 0, b: 1, x: 2, y: 3}); // Custom mapping
`}
/>
### Multiple Gamepads
Strudel supports multiple gamepads. You can specify the gamepad index to connect to different devices.
Make sure to press buttons on all connected gamepads before hitting play, so the browser can properly detect them.
<MiniRepl
client:idle
+119 -28
View File
@@ -1,35 +1,62 @@
// @strudel/gamepad/index.mjs
import { signal } from '@strudel/core';
import { logger } 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,
const buttonMapSettings = {
XBOX: {
// XBOX mapping default
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,
},
NES: {
// Nintendo mapping
a: 1,
b: 0,
x: 3,
y: 2,
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) {
constructor(timeWindow = 1000, mapping) {
this.sequence = [];
this.timeWindow = timeWindow;
this.lastInputTime = 0;
this.buttonStates = Array(16).fill(0); // Track previous state of each button
this.buttonMap = mapping;
// Button mapping for character inputs
}
@@ -44,7 +71,8 @@ class ButtonSequenceDetector {
}
// Store the button name instead of index
const buttonName = Object.keys(buttonMap).find((key) => buttonMap[key] === buttonIndex) || buttonIndex.toString();
const buttonName =
Object.keys(this.buttonMap).find((key) => this.buttonMap[key] === buttonIndex) || buttonIndex.toString();
this.sequence.push({
input: buttonName,
@@ -87,9 +115,9 @@ class ButtonSequenceDetector {
// Check if either the input matches directly or they refer to the same button in the map
return (
input === target ||
buttonMap[input] === buttonMap[target] ||
this.buttonMap[input] === this.buttonMap[target] ||
// Also check if the numerical index matches
buttonMap[input] === parseInt(target)
this.buttonMap[input] === parseInt(target)
);
})
? 1
@@ -98,9 +126,10 @@ class ButtonSequenceDetector {
}
class GamepadHandler {
constructor(index = 0) {
constructor(index = 0, mapping) {
// Add index parameter
this._gamepads = {};
this._mapping = mapping;
this._activeGamepad = index; // Use provided index
this._axes = [0, 0, 0, 0];
this._buttons = Array(16).fill(0);
@@ -143,12 +172,66 @@ class GamepadHandler {
}
}
// Add utility function to list all connected gamepads
export const listGamepads = () => {
const gamepads = navigator.getGamepads();
const connectedGamepads = Array.from(gamepads)
.filter((gp) => gp !== null)
.map((gp) => ({
index: gp.index,
id: gp.id,
mapping: gp.mapping,
buttons: gp.buttons.length,
axes: gp.axes.length,
connected: gp.connected,
timestamp: gp.timestamp,
}));
// Format the gamepads info into a readable string
const gamepadsInfo = connectedGamepads.map((gp) => `${gp.index}: ${gp.id}`).join('\n');
logger(`[gamepad] available gamepads:\n${gamepadsInfo}`);
return connectedGamepads;
};
// 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);
export const gamepad = (index = 0, mapping = 'XBOX') => {
// list connected gamepads
const connectedGamepads = listGamepads();
// Check if the requested gamepad index exists
const requestedGamepad = connectedGamepads.find((gp) => gp.index === index);
if (!requestedGamepad) {
throw new Error(
`[gamepad] gamepad at index ${index} not found. available gamepads: ${connectedGamepads.map((gp) => gp.index).join(', ')}`,
);
}
// Handle button mapping
let buttonMap = buttonMapSettings.XBOX;
if (typeof mapping === 'string') {
buttonMap = buttonMapSettings[mapping.toUpperCase()];
} else if (typeof mapping === 'object') {
buttonMap = { ...buttonMapSettings.XBOX, ...mapping };
// Check that all mapping values are valid button indices
const maxButtons = requestedGamepad.buttons; // Standard gamepad has 16 buttons
for (const [key, value] of Object.entries(mapping)) {
if (typeof value !== 'number' || value < 0 || value >= maxButtons) {
throw new Error(
`[gamepad] invalid button mapping for '${key}': ${value}. Must be a number between 0 and ${maxButtons - 1}`,
);
}
}
}
if (!buttonMap) {
throw new Error(`[gamepad] button mapping '${mapping}' not found`);
}
const handler = new GamepadHandler(index, buttonMap);
const sequenceDetector = new ButtonSequenceDetector(2000, buttonMap);
// Base signal that polls gamepad state and handles sequence detection
const baseSignal = signal((t) => {
@@ -218,8 +301,14 @@ export const gamepad = (index = 0) => {
return baseSignal.fmap(() => sequenceDetector.checkSequence(sequence));
};
const checkSequence = btnSequence;
const sequence = btnSequence;
const btnSeq = btnSequence;
const btnseq = btnSeq;
const btnseq = btnSequence;
const seq = btnSequence;
logger(
`[gamepad] connected to gamepad ${index} (${requestedGamepad.id}) with ${typeof mapping === 'object' ? 'custom' : mapping} mapping`,
);
// Return an object with all controls
return {
@@ -234,9 +323,11 @@ export const gamepad = (index = 0) => {
]),
),
checkSequence,
sequence,
btnSequence,
btnSeq,
btnseq,
seq,
raw: baseSignal,
};
};
+7 -35
View File
@@ -52,7 +52,6 @@ export function parseControlsFromHap(hap, cps) {
controls.unit === 'c' && controls.speed != null && (controls.speed = controls.speed / cps);
const channels = controls.channels;
channels != undefined && (controls.channels = JSON.stringify(channels));
return controls;
}
@@ -64,42 +63,16 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
const keyvals = Object.entries(controls).flat();
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
const msg = { address: '/dirt/play', args: keyvals, timestamp: ts };
if ('oschost' in hap.value) {
msg['host'] = hap.value['oschost'];
}
if ('oscport' in hap.value) {
msg['port'] = hap.value['oscport'];
}
ws.send(JSON.stringify(msg));
}
export async function superdirtTrigger(hap, currentTime, cps = 1, targetTime) {
const ws = await connect();
const controls = parseControlsFromHap(hap, cps);
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
let oschost = '127.0.0.1';
let oscport = 57120;
let bushost = oschost;
let busport = 57110;
for (const [k, v] of Object.entries(controls)) {
if (k.startsWith('^')) {
const bus_idval = v;
const msg = JSON.stringify({
oschost: bushost,
oscport: busport,
address: '/c_set',
args: bus_idval,
timestamp: ts,
});
// console.log('bus message', msg);
ws.send(msg);
// So they aren't sent with trigger messages
delete controls[k];
}
}
if (hap.hasOnset()) {
const keyvals = Object.entries(controls).flat();
ws.send(JSON.stringify({ oschost, oscport, address: '/dirt/play', args: keyvals, timestamp: ts }));
}
}
/**
*
* Sends each hap as an OSC message, which can be picked up by SuperCollider or any other OSC-enabled software.
@@ -110,4 +83,3 @@ export async function superdirtTrigger(hap, currentTime, cps = 1, targetTime) {
* @returns Pattern
*/
export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
export const superdirt = register('superdirt', (pat) => pat._processParts().onTrigger(superdirtTrigger));
+13 -9
View File
@@ -12,13 +12,18 @@ import { WebSocketServer } from 'ws';
import osc from 'osc';
const WS_PORT = 8080; // WebSocket server port
const OSC_REMOTE_IP = '127.0.0.1';
const OSC_REMOTE_PORT = 57120;
const udpPort = new osc.UDPPort({
localAddress: '0.0.0.0',
localPort: 0,
remoteAddress: OSC_REMOTE_IP,
remotePort: OSC_REMOTE_PORT,
});
udpPort.open();
console.log(`[Sending OSC] ${OSC_REMOTE_IP}:${OSC_REMOTE_PORT}`);
udpPort.on('error', (e) => {
console.log('Error: ', e);
@@ -31,24 +36,23 @@ wss.on('connection', (ws) => {
console.log('New WebSocket connection');
ws.on('message', (message) => {
let oschost = '127.0.0.1';
let oscport = 57120;
let osc_host = '127.0.0.1';
let osc_port = 57120;
try {
const data = JSON.parse(message);
if ('oschost' in data) {
oschost = data['oschost'];
delete data['oschost'];
if ('host' in data) {
osc_host = data['host'];
}
if ('oscport' in data) {
oscport = data['oscport'];
delete data['oscport'];
if ('port' in data) {
osc_port = data['port'];
}
let msg = { address: data['address'], args: data['args'] };
if ('timestamp' in data) {
msg = { timeTag: osc.timeTag(0, data['timestamp']), packets: [msg] };
}
udpPort.send(msg, oschost, oscport);
udpPort.send(msg, osc_host, osc_port);
} catch (err) {
console.error('Error parsing message:', err);
}