/* eslint-disable no-await-in-loop */
import MoveBuilder from './util/move-builder.js';
import WorkerTimer from './util/worker-timer.js';
import {
clamp, round, has, createConstantProperty, validNumber, isGeneratorFunction
} from './util/util.js';
import validator from './util/validator.js';
import OSR_CONFIG from './util/osr-config.js';
import GeneratorBehavior from './behaviors/generator-behavior.js'; // eslint-disable-line import/no-cycle
class Ayva {
#devices = [];
#axes = {};
#frequency = 50; // Hz
#movements = new Set();
#nextMovementId = 1;
#nextBehaviorId = 1;
#currentBehaviorId = null;
#performing = false;
#timer;
#sleepResolves = new Set();
#readyResolves = new Set();
defaultRamp = Ayva.RAMP_COS;
static get precision () {
// Decimals to round to for internal values.
return 10;
}
static get maxFrequency () {
return 250;
}
get performing () {
return this.#performing;
}
get axes () {
const result = {};
Object.keys(this.#axes).forEach((key) => {
// Ensure that the result object is immutable by using getAxis()
result[key] = this.getAxis(key);
});
return result;
}
get frequency () {
return this.#frequency;
}
set frequency (value) {
if (!validNumber(value, 1, Ayva.maxFrequency)) {
throw new Error(`Invalid frequency ${value}. Frequency must be a number between 1 and ${Ayva.maxFrequency}.`);
}
this.#frequency = value;
}
get period () {
return this.#period;
}
get #period () {
return 1 / this.#frequency;
}
/**
* Create a new instance of Ayva with the specified configuration.
*
* @param {Object} [config]
* @param {String} [config.name] - the name of this configuration
* @param {String} [config.defaultAxis] - the default axis to command when no axis is specified
* @param {Object[]} [config.axes] - an array of axis configurations (see {@link Ayva#configureAxis})
* @class Ayva
*/
constructor (config) {
createConstantProperty(this, '$', {});
if (config) {
this.#configure(config);
}
if (typeof Worker === 'undefined') {
this.#timer = {
// Default timer is just a basic timeout.
sleep (seconds) {
return new Promise((resolve) => {
setTimeout(resolve, seconds * 1000);
});
},
now () {
return performance.now() / 1000;
},
};
} else {
this.#timer = new WorkerTimer();
}
}
/**
* Setup this Ayva instance with the default configuration (a six axis stroker).
*
* @example
* const ayva = new Ayva().defaultConfiguration();
*
* @returns the instance of Ayva
*/
defaultConfiguration () {
this.#configure(OSR_CONFIG);
return this;
}
/**
* Get the timer that Ayva uses to time movements.
*
* @returns the timer
*/
getTimer () {
return this.#timer;
}
/**
* Perform the specified behavior until it completes or is explicitly stopped.
* If another behavior is running, it will be stopped.
*
* For full details on how to use this method, see the {@tutorial behavior-api} tutorial.
*
* @param {GeneratorBehavior|Function|Object} behavior - the behavior to perform.
*/
async do (behavior) {
this.#stop();
const behaviorId = this.#nextBehaviorId++;
this.#currentBehaviorId = behaviorId;
while (this.#performing) {
await this.sleep();
}
this.#performing = true;
const computedBehavior = this.#computeBehavior(behavior);
while (this.#currentBehaviorId === behaviorId && !computedBehavior.complete) {
try {
await computedBehavior.perform(this);
// Allow any moves or sleeps that were queued to complete.
await this.ready();
} catch (error) {
console.error('Error performing behavior:', error?.stack); // eslint-disable-line no-console
break;
}
}
this.#performing = false;
if (this.#currentBehaviorId !== behaviorId) {
// Behavior was stopped before it completed.
return false;
}
this.#currentBehaviorId = null;
return true;
}
/**
* Performs movements along one or more axes. This is a powerful method that can synchronize
* axis movement while allowing for fine control over position, speed, or move duration.
* For full details on how to use this method, see the {@tutorial motion-api} tutorial.
*
* @example
* ayva.move({
* axis: 'stroke',
* to: 0,
* speed: 1,
* },{
* axis: 'twist',
* to: 0.5,
* duration: 1,
* });
*
* @param {...Object} movements
* @return {Promise} a promise that resolves with the boolean value true when all movements have finished, or false if the move was cancelled.
*/
move (...movements) {
if (!this.#devices || !this.#devices.length) {
throw new Error('No output devices have been added.');
}
validator.validateMovements(movements, this.#axes, this.defaultAxis);
return this.#asyncMove(...movements);
}
/**
* Wait until ayva is not doing anything (neither moving nor sleeping).
*
* @return {Promise} a promise that resolves when there are no more moves or sleeps queued.
*/
ready () {
if (this.#sleepResolves.size || this.#movements.size) {
return new Promise((resolve) => {
this.#readyResolves.add(resolve);
});
}
return this.sleep();
}
/**
* Creates a MoveBuilder for this instance.
*
* @returns the new move builder.
*/
moveBuilder () {
return new MoveBuilder(this);
}
/**
* Moves all axes to their default positions.
*
* @param {Number} [speed = 0.5] - optional speed of the movement.
* @return {Promise} A promise that resolves when the movements are finished.
*/
async home (speed = 0.5) {
const movements = this.#getAxesArray()
.map((axis) => {
const movement = {
axis: axis.name,
to: axis.defaultValue,
};
if (axis.type !== 'boolean') {
movement.speed = speed;
}
return movement;
});
if (movements.length) {
return this.move(...movements);
}
console.warn('No linear or rotation axes configured.'); // eslint-disable-line no-console
return Promise.resolve(false);
}
/**
* Cancels all running or pending movements, clears the current behavior (if any), and cancels any sleeps.
*/
stop () {
// TODO: Add on stop notification here once event listening is implemented.
this.#stop();
}
/**
* Asynchronously sleep for the specified number of seconds (or until stop() is called).
*
* @param {Number} seconds
* @returns {Promise} a Promise that resolves with the value true if the time elapses. false if the sleep is cancelled.
*/
sleep (seconds) {
let sleepResolve;
const sleepCanceller = new Promise((resolve) => {
this.#sleepResolves.add(resolve);
sleepResolve = resolve;
});
return Promise.any([
this.#timer.sleep(seconds).then(() => true),
sleepCanceller.then(() => false),
]).finally(() => {
this.#sleepResolves.delete(sleepResolve);
this.#checkNotifyReady();
});
}
/**
* Configures a new axis. If an axis with the same name has already been configured, it will be overridden.
*
* @example
* const ayva = new Ayva();
*
* ayva.configureAxis({
* name: 'L0',
* type: 'linear',
* alias: 'stroke',
* max: 0.9,
* min: 0.3,
* });
*
* @param {Object} axisConfig - axis configuration object
* @param {String} axisConfig.name - the machine name of this axis (such as L0, R0, etc...)
* @param {String} axisConfig.type - linear, rotation, or auxiliary
* @param {String} [axisConfig.alias] - an alias used to refer to this axis
* @param {Number} [axisConfig.max = 1] - specifies maximum value for this axis
* @param {Number} [axisConfig.min = 0] - specifies minimum value for this axis
*/
configureAxis (axisConfig) {
// TODO: Disallow 'execute' as an axis name.
const resultConfig = validator.validateAxisConfig(axisConfig);
const oldConfig = this.#axes[axisConfig.name];
if (oldConfig) {
resultConfig.value = oldConfig.value;
resultConfig.lastValue = oldConfig.lastValue;
delete this.#axes[oldConfig.alias];
delete this.$[oldConfig.alias];
}
this.#axes[axisConfig.name] = resultConfig;
this.#createAxisMoveBuilder(axisConfig.name);
if (axisConfig.alias) {
if (this.#axes[axisConfig.alias]) {
throw new Error(`Alias already refers to another axis: ${axisConfig.alias}`);
}
this.#axes[axisConfig.alias] = resultConfig;
this.#createAxisMoveBuilder(axisConfig.alias);
}
}
/**
* Fetch an immutable object containing the properties for an axis.
*
* @param {String} name - the name or alias of the axis to get.
* @return {Object} axisConfig - an immutable object of axis properties.
*/
getAxis (name) {
const fetchedAxis = this.#axes[name];
if (fetchedAxis) {
const axis = {};
Object.keys(fetchedAxis).forEach((key) => {
createConstantProperty(axis, key, fetchedAxis[key]);
});
return axis;
}
return undefined;
}
/**
* Fetch an array of the axes.
*/
getAxes () {
return this.#getAxesArray().map((axis) => ({
// Ghetto deep copy, but its the most optimal.
name: axis.name,
alias: axis.alias,
type: axis.type,
defaultValue: axis.defaultValue,
max: axis.max,
min: axis.min,
value: axis.value,
lastValue: axis.lastValue,
resetOnStop: axis.resetOnStop,
}));
}
/**
* Update the limits for the specified axis.
*
* @param {String} axis
* @param {Number} from - value between 0 and 1
* @param {Number} to - value between 0 and 1
*/
updateLimits (axis, from, to) {
const isInvalid = (value) => !Number.isFinite(value) || value < 0 || value > 1;
if (isInvalid(from) || isInvalid(to) || from === to) {
throw new Error(`Invalid limits: min = ${from}, max = ${to}`);
}
if (!this.#axes[axis]) {
throw new Error(`Invalid axis: ${axis}`);
}
this.#axes[axis].min = Math.min(from, to);
this.#axes[axis].max = Math.max(from, to);
}
/**
* Live update axis values.
*
* @param {Object} axisValueMap - axis to value map
*/
setValues (axisValueMap) {
// TODO: Validate instead of silently ignoring invalid values?
const axisValues = Object.entries(axisValueMap).map(([axis, value]) => ({ axis, value }));
this.#writeAxisValues(axisValues);
}
/**
* Registers a new output. Ayva outputs commands to all connected outputs.
* More than one output can be specified.
*
* @param {...Function|Object} output - a function or an object with a write() method.
*/
addOutput (...output) {
this.addOutputDevice(...output);
}
/**
* Return a list of all outputs.
*/
getOutput () {
return this.getOutputDevices();
}
/**
* Remove the specified output.
*
* @param {Object} output - the output to remove.
*/
removeOutput (output) {
this.removeOutputDevice(output);
}
/**
* Registers a new output device. Ayva outputs commands to all connected devices.
* More than one device can be specified.
*
* @deprecated since version 0.13.0. Use addOutput() instead.
* @param {...Object} device - a function or an object with a write method.
*/
addOutputDevice (...devices) {
const resultDevices = devices.map((device) => {
const isWritable = device && device.write && device.write instanceof Function;
const isFunction = device instanceof Function;
if (!isWritable && !isFunction) {
throw new Error(`Invalid device: ${device}`);
}
return isWritable ? device : { write: device };
});
this.#devices.push(...resultDevices);
}
/**
* Return a list of all output devices.
* @deprecated since version 0.13.0. Use getOutput() instead.
*/
getOutputDevices () {
return [...this.#devices];
}
/**
* Remove the specified device.
*
* @deprecated since version 0.13.0. Use removeOutput() instead.
* @param {Object} device - the device to remove.
*/
removeOutputDevice (device) {
const index = this.#devices.indexOf(device);
if (index !== -1) {
this.#devices.splice(index, 1);
}
}
async #asyncMove (...movements) {
const movementId = this.#nextMovementId++;
this.#movements.add(movementId);
while (this.#movements.has(movementId) && this.#movements.values().next().value !== movementId) {
// Wait until current movements have completed to proceed.
await this.sleep();
}
if (!this.#movements.has(movementId)) {
// This move must have been cancelled.
return false;
}
return this.#performMovements(movementId, movements).finally(() => {
this.#movements.delete(movementId);
this.#checkNotifyReady();
});
}
#stop () {
this.#currentBehaviorId = null;
this.#movements.clear();
this.#sleepResolves.forEach((resolve) => resolve());
const resetValues = {};
this.#getAxesArray().forEach((axis) => {
if (axis.resetOnStop) {
resetValues[axis.name] = axis.defaultValue;
}
});
if (Object.keys(resetValues).length) {
this.setValues(resetValues);
}
}
/**
* Add the start of a move builder chain to $ for the specified axis.
* Also add shortcut properties for value, min, and max to each axis.
*/
#createAxisMoveBuilder (axis) {
Object.defineProperty(this.$, axis, {
value: (...args) => this.moveBuilder()[axis](...args),
writeable: false,
configurable: true,
enumerable: true,
});
Object.defineProperty(this.$[axis], 'value', {
get: () => this.#axes[axis].value,
set: (target) => {
const { type } = this.#axes[axis];
if (type !== 'boolean' && !validNumber(target, 0, 1)) {
// TODO: Move this validation out into a place it can be reused for setValues() method?
throw new Error(`Invalid value: ${target}`);
}
this.#writeAxisValues([{
axis,
value: target,
}]);
},
});
Object.defineProperty(this.$[axis], 'lastValue', {
get: () => this.#axes[axis].lastValue,
});
Object.defineProperty(this.$[axis], 'defaultValue', {
get: () => this.#axes[axis].defaultValue,
});
Object.defineProperty(this.$[axis], 'min', {
get: () => this.#axes[axis].min,
});
Object.defineProperty(this.$[axis], 'max', {
get: () => this.#axes[axis].max,
});
}
/**
* Setup the configuration.
*/
#configure (config) {
this.name = config.name;
this.defaultAxis = config.defaultAxis;
this.#frequency = (config.frequency || this.#frequency);
if (config.axes) {
config.axes.forEach((axis) => {
this.configureAxis(axis);
});
}
}
#computeBehavior (value) {
if (typeof value === 'function' && !(value instanceof GeneratorBehavior)) {
if (isGeneratorFunction(value)) {
return new GeneratorBehavior(value);
}
return {
perform: value,
};
}
return value;
}
/**
* Writes the specified command out to all connected devices.
*/
#write (command) {
for (const device of this.#devices) {
device.write(command);
}
}
async #performMovements (movementId, movements) {
const allProviders = this.#createValueProviders(movements);
const { duration, stepCount } = this.#computeMaxDurationAndStepCount(allProviders);
const immediateProviders = allProviders.filter((provider) => !provider.parameters.stepCount);
const stepProviders = allProviders.filter((provider) => !!provider.parameters.stepCount);
this.#executeProviders(immediateProviders, 0);
let errorCorrection = 0;
const startTime = this.#timer.now();
if (stepCount) {
for (let index = 0; index < stepCount; index++) {
const unfinishedProviders = stepProviders.filter((provider) => index < provider.parameters.stepCount);
this.#executeProviders(unfinishedProviders, index);
errorCorrection = await this.#stepSleep(index, stepCount, duration, startTime, errorCorrection);
if (!this.#movements.has(movementId)) {
// This move was cancelled.
return false;
}
}
} else {
// Always sleep at least a tick even when all providers are immediate.
await this.sleep(this.#period);
}
return true;
}
/**
* Sleep for a single step. Aims to sleep for this.#period seconds on average. This method corrects for
* deviations in the underlying timer.
*
* TODO: Maybe add a threshold for the error.
*
* @returns the new error correction
*/
async #stepSleep (index, stepCount, duration, startTime, errorCorrection) {
const clampPeriod = (period) => Math.max(period, 1 / Ayva.maxFrequency);
if (index === stepCount - 1) {
// This shenanigans is to (attempt to) account for the fact that a move is
// an integer number of steps but a duration may be fractional. In the final step
// we may have time remaining that is less than the period.
const currentElapsed = this.#timer.now() - startTime;
const remaining = Math.min(Math.max(duration - currentElapsed, 0), this.#period);
await this.sleep(clampPeriod(remaining));
} else {
await this.sleep(clampPeriod(this.#period - errorCorrection));
}
const actualElapsed = this.#timer.now() - startTime;
const expectedElapsed = (index + 1) * this.#period;
return actualElapsed - expectedElapsed;
}
#executeProviders (providers, index) {
const axisValues = providers.map((provider) => this.#executeProvider(provider, index));
this.#writeAxisValues(axisValues);
}
#executeProvider (provider, index) {
const time = index * this.#period;
const { parameters, valueProvider } = provider;
const { duration } = parameters;
const nextValue = valueProvider({
...parameters,
time,
index,
period: this.#period,
frequency: this.#frequency,
currentValue: this.#axes[parameters.axis].value,
x: Math.min(1, (index + 1) / (duration * this.#frequency)),
});
const notNullOrUndefined = nextValue !== null && nextValue !== undefined; // Allow null or undefined to indicate no movement.
if (!this.#isValidAxisValue(nextValue) && notNullOrUndefined) {
console.warn(`Invalid value provided: ${nextValue}`); // eslint-disable-line no-console
}
return {
axis: parameters.axis,
value: Number.isFinite(nextValue) ? clamp(round(nextValue, Ayva.precision), 0, 1) : nextValue,
};
}
#writeAxisValues (axisValues) {
const filteredAxisValues = axisValues.filter(({ value }) => this.#isValidAxisValue(value));
const tcodes = filteredAxisValues.map(({ axis, value }) => this.#tcode(axis, value));
if (tcodes.length) {
this.#write(`${tcodes.join(' ')}\n`);
filteredAxisValues.forEach(({ axis, value }) => {
this.#axes[axis].lastValue = this.#axes[axis].value;
this.#axes[axis].value = value;
});
}
}
#isValidAxisValue (value) {
return Number.isFinite(value) || typeof value === 'boolean';
}
/**
* Converts the value into a standard live command TCode string for the specified axis. (i.e. 0.5 -> L0500)
* If the axis is a boolean axis, true values get mapped to 999 and false gets mapped to 000.
*
* @param {String} axis
* @param {Number} value
* @returns {String} the TCode string
*/
#tcode (axis, value) {
let valueText;
if (typeof value === 'boolean') {
valueText = value ? '9999' : '0000';
} else {
const { min, max } = this.#axes[axis];
const normalizedValue = round(value * 0.9999, 4); // Convert values from range (0, 1) to (0, 0.9999)
const scaledValue = (max - min) * normalizedValue + min;
valueText = `${clamp(round(scaledValue * 10000), 0, 9999)}`.padStart(4, '0');
}
return `${this.#axes[axis].name}${valueText}`;
}
/**
* Create value providers with initial parameters.
*
* Precondition: Each movement is a valid movement per the Motion API.
* @param {Object[]} movements
* @returns {Object[]} - array of value providers with parameters.
*/
#createValueProviders (movements) {
const { parameterObjects, maxDuration } = this.#createParameterObjects(movements);
this.#populateDurationAndStepCount(parameterObjects, maxDuration);
// Create the actual value providers.
return parameterObjects.map((parameters) => {
const provider = {};
if (!has(parameters, 'value')) {
// Create a value provider from parameters.
if (this.#axes[parameters.axis].type === 'boolean') {
provider.valueProvider = () => parameters.to;
} else if (parameters.to !== parameters.from) {
provider.valueProvider = this.defaultRamp;
} else {
// No movement.
provider.valueProvider = () => {};
}
} else {
// User provided value provider.
provider.valueProvider = parameters.value;
}
delete parameters.sync;
delete parameters.value;
provider.parameters = parameters;
return provider;
});
}
#createParameterObjects (movements) {
let maxDuration = 0;
const parameterObjects = movements.map((movement) => {
// Initialize all parameters that we can deduce.
const axis = movement.axis || this.defaultAxis;
const parameters = {
...movement,
axis,
from: this.#axes[axis].value,
period: this.#period,
};
if (has(movement, 'to')) {
const distance = movement.to - parameters.from;
const absoluteDistance = Math.abs(distance);
if (has(movement, 'duration')) {
// { to: <number>, duration: <number> }
parameters.speed = round(absoluteDistance / movement.duration, Ayva.precision);
} else if (has(movement, 'speed')) {
// { to: <number>, speed: <number> }
// Uncomment the below to re-enable speed scaling.
// const axisScale = 1 / Math.abs(this.#axes[axis].max - this.#axes[axis].min);
// result.speed = movement.speed * axisScale;
parameters.duration = round(absoluteDistance / parameters.speed, Ayva.precision);
}
parameters.direction = distance > 0 ? 1 : distance < 0 ? -1 : 0; // eslint-disable-line no-nested-ternary
}
if (has(parameters, 'duration')) {
maxDuration = Math.max(parameters.duration, maxDuration);
}
return parameters;
});
return { maxDuration, parameterObjects };
}
#populateDurationAndStepCount (parameterObjects, maxDuration) {
const movementsByAxis = parameterObjects.reduce((map, p) => {
map[p.axis] = p;
if (this.#axes[p.axis].alias) {
map[this.#axes[p.axis].alias] = p;
}
return map;
}, {});
parameterObjects.forEach((parameters) => {
// We need to compute the duration for any movements we couldn't in the first pass.
// This will be either implicit or explicit sync movements.
if (has(parameters, 'sync')) {
// Excplicit sync.
let syncMovement = parameters;
while (has(syncMovement, 'sync')) {
syncMovement = movementsByAxis[syncMovement.sync];
}
parameters.duration = syncMovement.duration || maxDuration;
if (has(parameters, 'to')) {
// Now we can compute a speed.
parameters.speed = round(Math.abs(parameters.to - parameters.from) / parameters.duration, Ayva.precision);
}
} else if (!has(parameters, 'duration') && this.#axes[parameters.axis].type !== 'boolean') {
// Implicit sync to max duration.
parameters.duration = maxDuration;
}
if (has(parameters, 'duration')) {
parameters.stepCount = Math.ceil(parameters.duration * this.#frequency);
} // else if (this.#axes[movement.axis].type !== 'boolean') {
// By this point, the only movements without a duration should be boolean.
// This should literally never happen because of validation. But including here for debugging and clarity.
// fail(`Unable to compute duration for movement along axis: ${movement.axis}`);
// }
});
}
#computeMaxDurationAndStepCount (valueProviders) {
let stepCount = 0;
let duration = 0;
valueProviders.forEach((provider) => {
const nextStepCount = provider.parameters.stepCount;
const nextDuration = provider.parameters.duration;
if (nextStepCount) {
stepCount = Math.max(nextStepCount, stepCount);
}
if (nextDuration) {
duration = Math.max(nextDuration, duration);
}
});
return { duration, stepCount };
}
#getAxesArray () {
const uniqueAxes = {};
Object.values(this.#axes).forEach((axis) => {
uniqueAxes[axis.name] = axis;
});
function sortByName (a, b) {
return a.name > b.name ? 1 : -1;
}
return Object.values(uniqueAxes).sort(sortByName);
}
#checkNotifyReady () {
if (this.#sleepResolves.size === 0 && this.#movements.size === 0 && this.#readyResolves.size) {
for (const resolve of this.#readyResolves) {
resolve();
}
this.#readyResolves.clear();
}
}
/**
* Convert the function provided into a ramp function.
*
* @param {Function} fn
* @returns the new ramp function
*/
static ramp (fn) {
return (parameters) => {
const { from, to } = parameters;
return from + ((to - from) * fn(parameters));
};
}
/**
* Value provider that generates motion towards a target position with constant velocity.
*/
static RAMP_LINEAR (parameters) {
const fn = ({ x }) => x;
return Ayva.ramp(fn)(parameters);
}
/**
* Value provider that generates motion towards a target position that resembles part of a cos wave (0 - 180 degrees).
*/
static RAMP_COS (parameters) {
const fn = ({ x }) => (-Math.cos(Math.PI * x) / 2) + 0.5;
return Ayva.ramp(fn)(parameters);
}
/**
* Value provider that generates motion towards a target position in the shape of the latter half of a parabola.
* This creates the effect of "falling" towards the target position.
*/
static RAMP_PARABOLIC (parameters) {
const fn = ({ x }) => x * x;
return Ayva.ramp(fn)(parameters);
}
/**
* Value provider that generates motion towards a target position in the shape of the first half of an upside down parabola.
* This creates the effect of "launching" towards the target position.
*/
static RAMP_NEGATIVE_PARABOLIC (parameters) {
const fn = ({ x }) => -((x - 1) ** 2) + 1;
return Ayva.ramp(fn)(parameters);
}
/**
* Creates a value provider that generates oscillatory motion. The formula is:
*
* cos(θ + phase·π/2 + ecc·sin(θ + phase·π/2))
*
* The result is translated and scaled to fit the range and beats per minute specified.
* This formula was created by [Tempest MAx]{@link https://www.patreon.com/tempestvr}—loosely based
* on orbital motion calculations. Hence, tempestMotion.
*
* See [this graph]{@link https://www.desmos.com/calculator/vnfke1rprt} of the function
* where you can adjust the parameters to see how they affect the motion.
*
* @example
* // Note: These examples use Move Builders from the Motion API.
*
* // Simple up/down stroke for 10 seconds.
* ayva.$.stroke(Ayva.tempestMotion(1, 0), 10).execute();
*
* // ... out of phase with a little eccentricity.
* ayva.$.stroke(Ayva.tempestMotion(1, 0, 1, 0.2), 10).execute();
*
* // ... at 30 BPM.
* ayva.$.stroke(Ayva.tempestMotion(1, 0, 1, 0.2, 30), 10).execute();
*
* @param {Number} from - the start of the range of motion
* @param {Number} to - the end of the range of motion
* @param {Number} [phase] - the phase of the wave in multiples of π/2
* @param {Number} [ecc] - the eccentricity of the wave
* @param {Number} [bpm] - beats per minute
* @param {Number} [shift] - additional phase shift of the wave in radians
* @returns the value provider.
*//**
* Creates a value provider that generates oscillatory motion. The formula is:
*
* cos(θ + phase·π/2 + ecc·sin(θ + phase·π/2))
*
* The result is translated and scaled to fit the range and beats per minute specified.
* This formula was created by [Tempest MAx]{@link https://www.patreon.com/tempestvr}—loosely based
* on orbital motion calculations. Hence, tempestMotion.
*
* See [this graph]{@link https://www.desmos.com/calculator/vnfke1rprt} of the function
* where you can adjust the parameters to see how they affect the motion.
*
* @example
* // Note: These examples use Move Builders from the Motion API.
*
* // Simple up/down stroke for 10 seconds.
* ayva.$.stroke(Ayva.tempestMotion({ from: 1, to: 0}), 10).execute();
*
* // ... out of phase with a little eccentricity.
* ayva.$.stroke(Ayva.tempestMotion({ from: 1, to: 0, phase: 1, ecc: 0.2 }), 10).execute();
*
* // ... at 30 BPM.
* ayva.$.stroke(Ayva.tempestMotion({ from: 1, to: 0, phase: 1, ecc: 0.2, bpm: 30 }), 10).execute();
*
* @param {Object} params - the parameters of the motion.
* @returns the value provider.
*/
static tempestMotion (from, to, phase = 0, ecc = 0, bpm = 60, shift = 0) {
const params = typeof from === 'object' ? from : {
from, to, phase, ecc, bpm, shift,
};
return Ayva.#tempestMotion(params);
}
static #tempestMotion (params) {
params = { // eslint-disable-line no-param-reassign
from: 0,
to: 1,
phase: 0,
ecc: 0,
shift: 0,
bpm: 60,
...params,
};
validator.validateMotionParameters(params);
const {
from, to, phase, ecc, bpm, shift,
} = params;
const angularVelocity = (2 * Math.PI * bpm) / 60;
const scale = 0.5 * (to - from);
const midpoint = 0.5 * (to + from);
const provider = ({ index, frequency }) => {
const angle = (((index + 1) * angularVelocity) / frequency) + (0.5 * Math.PI * phase) + shift;
return midpoint - scale * Math.cos(angle + (ecc * Math.sin(angle)));
};
Ayva.#createConstantMotionProperties(provider, from, to, phase, ecc, bpm);
return provider;
}
/**
* Eccentric Parametric Oscillatory Parabolic Motion™
*
* @param {Number} from - the start of the range of motion
* @param {Number} to - the end of the range of motion
* @param {Number} [phase] - the phase of the motion in multiples of π/2
* @param {Number} [ecc] - the eccentricity of the motion
* @param {Number} [bpm] - beats per minute
* @param {Number} [shift] - additional phase shift of the motion in radians
* @returns the value provider
*//**
* Eccentric Parametric Oscillatory Parabolic Motion™
*
* @param {Object} params - the parameters of the motion.
* @returns the value provider
*/
static parabolicMotion (from, to, phase = 0, ecc = 0, bpm = 60, shift = 0) {
const params = typeof from === 'object' ? from : {
from, to, phase, ecc, bpm, shift,
};
return Ayva.#parabolicMotion(params);
}
static #parabolicMotion (params) {
// TODO: Thou shalt not repeat thyself.
params = { // eslint-disable-line no-param-reassign
from: 0,
to: 1,
phase: 0,
ecc: 0,
shift: 0,
bpm: 60,
...params,
};
validator.validateMotionParameters(params);
const {
from, to, phase, ecc, bpm, shift,
} = params;
const { sin, PI } = Math;
const angularVelocity = (2 * PI * bpm) / 60;
const scale = to - from;
const offset = to;
const mod = (a, b) => ((a % b) + b) % b; // Proper mathematical modulus operator.
const provider = ({ index, frequency }) => {
const angle = (((index + 1) * angularVelocity) / frequency) + (0.5 * PI * phase) + shift;
const x = (mod(angle, (2 * PI)) / PI) - 1 + (ecc / PI) * sin(angle);
return offset - scale * x * x;
};
Ayva.#createConstantMotionProperties(provider, from, to, phase, ecc, bpm);
return provider;
}
/**
* Eccentric Parametric Oscillatory Linear Motion™
*
* @param {Number} from - the start of the range of motion
* @param {Number} to - the end of the range of motion
* @param {Number} [phase] - the phase of the motion in multiples of π/2
* @param {Number} [ecc] - the eccentricity of the motion
* @param {Number} [bpm] - beats per minute
* @param {Number} [shift] - additional phase shift of the motion in radians
* @returns the value provider
*//**
* Eccentric Parametric Oscillatory Linear Motion™
*
* @param {Object} params - the parameters of the motion.
* @returns the value provider
*/
static linearMotion (from, to, phase = 0, ecc = 0, bpm = 60, shift = 0) {
const params = typeof from === 'object' ? from : {
from, to, phase, ecc, bpm, shift,
};
return Ayva.#linearMotion(params);
}
static #linearMotion (params) {
// TODO: Thou shalt not repeat thyself.
params = { // eslint-disable-line no-param-reassign
from: 0,
to: 1,
phase: 0,
ecc: 0,
shift: 0,
bpm: 60,
...params,
};
validator.validateMotionParameters(params);
const {
from, to, phase, ecc, bpm, shift,
} = params;
const { abs, sin, PI } = Math;
const angularVelocity = (2 * PI * bpm) / 60;
const scale = to - from;
const offset = to;
const mod = (a, b) => ((a % b) + b) % b; // Proper mathematical modulus operator.
const provider = ({ index, frequency }) => {
const angle = (((index + 1) * angularVelocity) / frequency) + (0.5 * PI * phase) + shift;
const x = (mod(angle, (2 * PI)) / PI) - 1 + (ecc / PI) * sin(angle);
return offset - scale * abs(x);
};
Ayva.#createConstantMotionProperties(provider, from, to, phase, ecc, bpm);
return provider;
}
static #createConstantMotionProperties (provider, from, to, phase, ecc, bpm) {
createConstantProperty(provider, 'from', from);
createConstantProperty(provider, 'to', to);
createConstantProperty(provider, 'phase', phase);
createConstantProperty(provider, 'ecc', ecc);
createConstantProperty(provider, 'bpm', bpm);
}
/**
* Creates a value provider that is a blend of the two value providers passed.
* The factor is the multiplier for the values generated by the second provider.
* The first provider's values will be multiplied by 1 - factor.
*
* @param {Function} firstProvider
* @param {Function} secondProvider
* @param {Number} factor - value between 0 and 1
*/
static blendMotion (firstProvider, secondProvider, factor) {
return (...args) => round((1 - factor) * firstProvider(...args) + factor * secondProvider(...args), Ayva.precision);
}
/**
* Return a copy of the default configuration.
*/
static get defaultConfiguration () {
return {
...OSR_CONFIG,
};
}
/**
* Maps a value from one range to another. The default target range is [0, 1].
* Does not constrain values to within the range.
*
* This function is analagous to the map() function in the arduino's math library:
*
* {@link https://www.arduino.cc/reference/en/language/functions/math/map/}
*
* @param {Number} value
* @param {Number} inMin
* @param {Number} inMax
* @param {Number} [outMin=0]
* @param {Number} [outMax=1]
*/
static map (value, inMin, inMax, outMin = 0, outMax = 1) {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
}
}
// Separate default export from the class declaration because of jsdoc shenanigans...
export default Ayva;
Source