From f33ec0e2afdbb9cb78258e9273dd4d0db2f47414 Mon Sep 17 00:00:00 2001 From: nkymut Date: Thu, 21 Nov 2024 12:01:17 +0800 Subject: [PATCH] Add Device Motion package Introduces smart-phone device's acceleration, gravity, rotation, and orientation signals for dynamic pattern creation in Strudel. --- packages/motion/README.md | 66 ++++++ packages/motion/index.mjs | 3 + packages/motion/motion.mjs | 366 +++++++++++++++++++++++++++++++++ packages/motion/package.json | 38 ++++ packages/motion/vite.config.js | 19 ++ website/package.json | 3 +- website/src/repl/util.mjs | 1 + 7 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 packages/motion/README.md create mode 100644 packages/motion/index.mjs create mode 100644 packages/motion/motion.mjs create mode 100644 packages/motion/package.json create mode 100644 packages/motion/vite.config.js diff --git a/packages/motion/README.md b/packages/motion/README.md new file mode 100644 index 000000000..5874b820f --- /dev/null +++ b/packages/motion/README.md @@ -0,0 +1,66 @@ +# @strudel/motion + +This package adds device motion sensing functionality to strudel Patterns. + +## Install + +```sh +npm i @strudel/motion --save +``` + +## Setup SSL for Local Development +`DeviceMotionEvent` only work over HTTPS, so you'll need to set up SSL for local development. +install SSL plugin for Vite +`pnpm install -D @vitejs/plugin-basic-ssl` + +add the basicSsl plugin to the defineConfig block in `strudel/website/astro.config.mjs` +``` +vite: { + plugins: [basicSsl()], + server: { + host: '0.0.0.0', // Ensures it binds to all network interfaces + // https: { + // key: '../../key.pem', // + // cert: '../../cert.pem', + // }, + }, +}, +``` + +generate SSL cert if its necessary + +`openssl req -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout key.pem -out cert.pem` + +## Usage + +| Motion | Long Names & Aliases | Description | +|----------------------------|-----------------------------------------------------------|------------------------------------------| +| Acceleration | accelerationX (accX), accelerationY (accY), accelerationZ (accZ) | X, Y, Z-axis acceleration values | +| Gravity | gravityX (gravX), gravityY (gravY), gravityZ (gravZ) | X, Y, Z-axis gravity values | +| Rotation | rotationAlpha (rotA, rotZ), rotationBeta (rotB, rotX), rotationGamma (rotG, rotY) | Rotation around alpha, beta, gamma axes and mapped to X, Y, Z | +| Orientation | orientationAlpha (oriA, oriZ), orientationBeta (oriB, oriX), orientationGamma (oriG, oriY) | Orientation alpha, beta, gamma values and mapped to X, Y, Z | +| Absolute Orientation | absoluteOrientationAlpha (absOriA, absOriZ), absoluteOrientationBeta (absOriB, absOriX), absoluteOrientationGamma (absOriG, absOriY) | Absolute orientation alpha, beta, gamma values and mapped to X, Y, Z | + +## Example + +``` +enableMotion() //enable DeviceMotion + +let tempo = 200 + +$_: accX.segment(16).gain().log() + +$:n("[0 1 3 1 5 4]/4") + .scale("Bb:lydian") + .sometimesBy(0.5,sub(note(12))) + .lpf(gravityY.range(20,1000)) + .lpq(gravityZ.range(1,30)) + .lpenv(gravityX.range(2,2)) + .gain(oriX.range(0.2,0.8)) + .room(oriZ.range(0,0.5)) + .attack(oriY.range(0,0.3)) + .delay(rotG.range(0,1)) + .decay(rotA.range(0,1)) + .attack(rotB.range(0,0.1)) + .sound("sawtooth").cpm(tempo) +``` \ No newline at end of file diff --git a/packages/motion/index.mjs b/packages/motion/index.mjs new file mode 100644 index 000000000..b315b6173 --- /dev/null +++ b/packages/motion/index.mjs @@ -0,0 +1,3 @@ +import './motion.mjs'; + +export * from './motion.mjs'; diff --git a/packages/motion/motion.mjs b/packages/motion/motion.mjs new file mode 100644 index 000000000..f0a4fb419 --- /dev/null +++ b/packages/motion/motion.mjs @@ -0,0 +1,366 @@ +// motion.mjs + +import { signal } from '../core/signal.mjs'; + +/** + * The accelerometer's x-axis value ranges from 0 to 1. + * @name accelerationX + * @return {Pattern} + * @synonyms accX + * @example + * n(accelerationX.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The accelerometer's y-axis value ranges from 0 to 1. + * @name accelerationY + * @return {Pattern} + * @synonyms accY + * @example + * n(accelerationY.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The accelerometer's z-axis value ranges from 0 to 1. + * @name accelerationZ + * @return {Pattern} + * @synonyms accZ + * @example + * n(accelerationZ.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's gravity x-axis value ranges from 0 to 1. + * @name gravityX + * @return {Pattern} + * @synonyms gravX + * @example + * n(gravityX.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's gravity y-axis value ranges from 0 to 1. + * @name gravityY + * @return {Pattern} + * @synonyms gravY + * @example + * n(gravityY.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's gravity z-axis value ranges from 0 to 1. + * @name gravityZ + * @return {Pattern} + * @synonyms gravZ + * @example + * n(gravityZ.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's rotation around the alpha-axis value ranges from 0 to 1. + * @name rotationAlpha + * @return {Pattern} + * @synonyms rotA, rotZ, rotationZ + * @example + * n(rotationAlpha.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's rotation around the beta-axis value ranges from 0 to 1. + * @name rotationBeta + * @return {Pattern} + * @synonyms rotB, rotX, rotationX + * @example + * n(rotationBeta.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's rotation around the gamma-axis value ranges from 0 to 1. + * @name rotationGamma + * @return {Pattern} + * @synonyms rotG, rotY, rotationY + * @example + * n(rotationGamma.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's orientation alpha value ranges from 0 to 1. + * @name orientationAlpha + * @return {Pattern} + * @synonyms oriA, oriZ, orientationZ + * @example + * n(orientationAlpha.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's orientation beta value ranges from 0 to 1. + * @name orientationBeta + * @return {Pattern} + * @synonyms oriB, oriX, orientationX + * @example + * n(orientationBeta.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's orientation gamma value ranges from 0 to 1. + * @name orientationGamma + * @return {Pattern} + * @synonyms oriG, oriY, orientationY + * @example + * n(orientationGamma.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's absolute orientation alpha value ranges from 0 to 1. + * @name absoluteOrientationAlpha + * @return {Pattern} + * @synonyms absOriA, absOriZ, absoluteOrientationZ + * @example + * n(absoluteOrientationAlpha.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's absolute orientation beta value ranges from 0 to 1. + * @name absoluteOrientationBeta + * @return {Pattern} + * @synonyms absOriB, absOriX, absoluteOrientationX + * @example + * n(absoluteOrientationBeta.segment(4).range(0,7)).scale("C:minor") + * + */ + +/** + * The device's absolute orientation gamma value ranges from 0 to 1. + * @name absoluteOrientationGamma + * @return {Pattern} + * @synonyms absOriG, absOriY, absoluteOrientationY + * @example + * n(absoluteOrientationGamma.segment(4).range(0,7)).scale("C:minor") + * + */ + +class DeviceMotionHandler { + constructor() { + this.GRAVITY = 9.81; + + // Initialize sensor values + this._acceleration = { + x: 0, + y: 0, + z: 0, + }; + + this._gravity = { + x: 0, + y: 0, + z: 0, + }; + + this._rotation = { + alpha: 0, + beta: 0, + gamma: 0, + }; + + this._orientation = { + alpha: 0, + beta: 0, + gamma: 0, + }; + + this._absoluteOrientation = { + alpha: 0, + beta: 0, + gamma: 0, + }; + + this._permissionStatus = 'unknown'; + } + + async requestPermissions() { + if (typeof DeviceMotionEvent?.requestPermission === 'function') { + try { + // iOS requires explicit permission + const motionPermission = await DeviceMotionEvent.requestPermission(); + const orientationPermission = await DeviceOrientationEvent.requestPermission(); + + this._permissionStatus = + motionPermission === 'granted' && orientationPermission === 'granted' ? 'granted' : 'denied'; + this.setupEventListeners(); + } catch (error) { + console.error('Permission request failed:', error); + this._permissionStatus = 'denied'; + } + } else { + this._permissionStatus = 'granted'; + this.setupEventListeners(); + } + } + + setupEventListeners() { + if (this._permissionStatus === 'granted') { + // Device Motion handler + window.addEventListener('devicemotion', this.handleDeviceMotion.bind(this), true); + window.addEventListener('deviceorientation', this.handleDeviceOrientation.bind(this), true); + window.addEventListener('deviceorientationabsolute', this.handleAbsoluteDeviceOrientation.bind(this), true); + } + } + + handleDeviceMotion(event) { + //console.log(event); + if (event.acceleration) { + // Normalize acceleration values to 0-1 range + this._acceleration.x = (event.acceleration.x + 1) / 2; + this._acceleration.y = (event.acceleration.y + 1) / 2; + this._acceleration.z = (event.acceleration.z + 1) / 2; + } + + if (event.accelerationIncludingGravity) { + // Normalize acceleration values to 0-1 range + this._gravity.x = (event.accelerationIncludingGravity.x + this.GRAVITY) / (2 * this.GRAVITY); + this._gravity.y = (event.accelerationIncludingGravity.y + this.GRAVITY) / (2 * this.GRAVITY); + this._gravity.z = (event.accelerationIncludingGravity.z + this.GRAVITY) / (2 * this.GRAVITY); + } + + if (event.rotationRate) { + // Normalize rotation values to 0-1 range + this._rotation.alpha = (event.rotationRate.alpha + 180) / 360; + this._rotation.beta = (event.rotationRate.beta + 180) / 360; + this._rotation.gamma = (event.rotationRate.gamma + 180) / 360; + } + } + + handleDeviceOrientation(event) { + this._orientation.alpha = event.alpha / 360; //a(0~360) + this._orientation.beta = (event.beta + 180) / 360; //b(-180~180) + this._orientation.gamma = (event.gamma + 90) / 180; //g(-90~90) + } + + handleAbsoluteDeviceOrientation(event) { + this._absoluteOrientation.alpha = event.alpha / 360; //a(0~360) + this._absoluteOrientation.beta = (event.beta + 180) / 360; //b(-180~180) + this._absoluteOrientation.gamma = (event.gamma + 90) / 180; //g(-90~90) + } + + // Getter methods for current values + getAcceleration() { + return this._acceleration; + } + getGravity() { + return this._gravity; + } + getRotation() { + return this._rotation; + } + getOrientation() { + return this._orientation; + } + getAbsoluteOrientation() { + return this._absoluteOrientation; + } +} + +// Create singleton instance +const deviceMotion = new DeviceMotionHandler(); + +// Export a function to request permission +export async function enableMotion() { + return deviceMotion.requestPermissions(); +} + +// Create signals for acceleration +export const accelerationX = signal(() => deviceMotion.getAcceleration().x); +export const accelerationY = signal(() => deviceMotion.getAcceleration().y); +export const accelerationZ = signal(() => deviceMotion.getAcceleration().z); + +// Aliases for shorter names +export const accX = accelerationX; +export const accY = accelerationY; +export const accZ = accelerationZ; + +// Create signals for gravity +export const gravityX = signal(() => deviceMotion.getGravity().x); +export const gravityY = signal(() => deviceMotion.getGravity().y); +export const gravityZ = signal(() => deviceMotion.getGravity().z); + +// Aliases for shorter names +export const gravX = gravityX; +export const gravY = gravityY; +export const gravZ = gravityZ; + +// Create signals for orientation +export const orientationAlpha = signal(() => deviceMotion.getOrientation().alpha); +export const orientationBeta = signal(() => deviceMotion.getOrientation().beta); +export const orientationGamma = signal(() => deviceMotion.getOrientation().gamma); +// Aliases for shorter names +export const orientationA = orientationAlpha; +export const orientationB = orientationBeta; +export const orientationG = orientationGamma; + +// Aliases mapping to X,Y,Z coordinates +export const orientationX = orientationBeta; +export const orientationY = orientationGamma; +export const orientationZ = orientationAlpha; + +// Short aliases for X,Y,Z +export const oriX = orientationX; +export const oriY = orientationY; +export const oriZ = orientationZ; + +// Create signals for absolute orientation +export const absoluteOrientationAlpha = signal(() => deviceMotion.getAbsoluteOrientation().alpha); +export const absoluteOrientationBeta = signal(() => deviceMotion.getAbsoluteOrientation().beta); +export const absoluteOrientationGamma = signal(() => deviceMotion.getAbsoluteOrientation().gamma); + +// Aliases for shorter names +export const absOriA = absoluteOrientationAlpha; +export const absOriB = absoluteOrientationBeta; +export const absOriG = absoluteOrientationGamma; + +// Aliases mapping to X,Y,Z coordinates +export const absoluteOrientationX = absoluteOrientationBeta; +export const absoluteOrientationY = absoluteOrientationGamma; +export const absoluteOrientationZ = absoluteOrientationAlpha; + +// Short aliases for X,Y,Z +export const absOriX = absoluteOrientationX; +export const absOriY = absoluteOrientationY; +export const absOriZ = absoluteOrientationZ; + +// Create signals for rotation +export const rotationAlpha = signal(() => deviceMotion.getRotation().alpha); +export const rotationBeta = signal(() => deviceMotion.getRotation().beta); +export const rotationGamma = signal(() => deviceMotion.getRotation().gamma); +export const rotationX = rotationBeta; +export const rotationY = rotationGamma; +export const rotationZ = rotationAlpha; + +// Aliases for shorter names +export const rotA = rotationAlpha; +export const rotB = rotationBeta; +export const rotG = rotationGamma; +export const rotX = rotationX; +export const rotY = rotationY; +export const rotZ = rotationZ; + +// // Bipolar versions (ranging from -1 to 1 instead of 0 to 1) +// export const accX2 = accX.toBipolar(); +// export const accY2 = accY.toBipolar(); +// export const accZ2 = accZ.toBipolar(); + +// export const rotA2 = rotA.toBipolar(); +// export const rotB2 = rotB.toBipolar(); +// export const rotG2 = rotG.toBipolar(); diff --git a/packages/motion/package.json b/packages/motion/package.json new file mode 100644 index 000000000..b0308a355 --- /dev/null +++ b/packages/motion/package.json @@ -0,0 +1,38 @@ +{ + "name": "@strudel/motion", + "version": "1.1.0", + "description": "DeviceMotion API 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": "^5.0.10" + } +} + \ No newline at end of file diff --git a/packages/motion/vite.config.js b/packages/motion/vite.config.js new file mode 100644 index 000000000..5df3edc1b --- /dev/null +++ b/packages/motion/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/website/package.json b/website/package.json index 942fee9c6..2e22c1dbb 100644 --- a/website/package.json +++ b/website/package.json @@ -33,14 +33,15 @@ "@strudel/hydra": "workspace:*", "@strudel/midi": "workspace:*", "@strudel/mini": "workspace:*", + "@strudel/motion": "workspace:*", "@strudel/osc": "workspace:*", "@strudel/serial": "workspace:*", "@strudel/soundfonts": "workspace:*", + "@strudel/tidal": "workspace:*", "@strudel/tonal": "workspace:*", "@strudel/transpiler": "workspace:*", "@strudel/webaudio": "workspace:*", "@strudel/xen": "workspace:*", - "@strudel/tidal": "workspace:*", "@supabase/supabase-js": "^2.39.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.10", diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs index 905e16b09..7eede36ea 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/motion'), ]; if (isTauri()) { modules = modules.concat([