Source

behaviors/tempest-stroke.js

/* eslint-disable max-classes-per-file */
/* eslint-disable no-use-before-define */
import GeneratorBehavior from './generator-behavior.js';
import Ayva from '../ayva.js';
import StrokeParameterProvider from '../util/stroke-parameter-provider.js';
import defaultTempestStrokeLibrary from '../util/tempest-stroke-library.js';
import {
  createConstantProperty, cloneDeep, has, validNumber
} from '../util/util.js';

/**
 * A behavior that allows specifying oscillatory motion on an arbitrary
 * number of axes with a formula loosely based on orbital motion calculations.
 * See the [Tempest Stroke Tutorial]{@link https://ayvajs.github.io/ayvajs-docs/tutorial-behavior-api-tempest-stroke.html}.
 */
class TempestStroke extends GeneratorBehavior {
  #angle;

  #bpm;

  #bpmProvider;

  #timer;

  #startTime;

  #startAngle;

  get angle () {
    return this.#angle;
  }

  set angle (rad) {
    this.#angle = rad;
  }

  get startAngle () {
    return this.#startAngle;
  }

  set startAngle (angle) {
    this.#startAngle = angle;
  }

  get bpm () {
    return this.#bpm;
  }

  get startTime () {
    return this.#startTime;
  }

  set startTime (time) {
    this.#startTime = time;
  }

  get timer () {
    return this.#timer;
  }

  static #granularity = 36;

  static #library = cloneDeep(defaultTempestStrokeLibrary);

  /**
   * How many slices to divide a stroke (180 degrees) into.
   * This controls how often a bpm provider is called per stroke.
   */
  static set granularity (value) {
    if (!validNumber(value, 1, 180)) {
      throw new Error(`Invalid granularity: ${value}`);
    }

    TempestStroke.#granularity = value;
  }

  static get granularity () {
    return TempestStroke.#granularity;
  }

  static get DEFAULT_PARAMETERS () {
    return {
      from: 0,
      to: 1,
      phase: 0,
      ecc: 0,
      shift: 0,
      noise: 0,
      motion: Ayva.tempestMotion,
    };
  }

  static get DEFAULT_TIMER () {
    return () => performance.now() / 1000;
  }

  static library = new Proxy({}, {
    get: function (target, key, receiver) {
      if (key in TempestStroke.#library) {
        return cloneDeep(TempestStroke.#library[key]);
      }

      return Reflect.get(target, key, receiver);
    },

    ownKeys: function () {
      return Object.keys(TempestStroke.#library);
    },

    getOwnPropertyDescriptor: function (target, key) {
      if (key in TempestStroke.#library) {
        return { enumerable: true, configurable: true, value: this[key] };
      }

      return undefined;
    },
  });

  static computeTargetAngle (angle, startAngle) {
    const traversed = Math.abs(angle - startAngle);
    const strokeCount = Math.floor(traversed / Math.PI);
    const targetAngle = ((strokeCount + 1) * Math.PI) + startAngle;

    if (targetAngle === angle) {
      // Because rounding errors... :(
      return ((strokeCount + 2) * Math.PI) + startAngle;
    }

    return targetAngle;
  }

  static update (key, value) {
    TempestStroke.#library[key] = cloneDeep(value);
  }

  static remove (key) {
    delete TempestStroke.#library[key];
  }

  static restoreLibrary () {
    TempestStroke.#library = cloneDeep(defaultTempestStrokeLibrary);
  }

  /**
   * Create a new tempest stroke with the specified config.
   *
   * @example
   * ayva.do(new TempestStroke({
   *   stroke: {
   *     from: 0,
   *     to: 1,
   *   },
   *   twist: {
   *     from: 0.25,
   *     to: 0.75,
   *     phase: 1,
   *   },
   * }));
   * @param {Object} config
   * @param {Number} [bpm=60]
   * @param {Number} [angle=0]
   * @param {Object} [timer=null]
   * @param {Number} [startTime=null]
   */
  constructor (config, bpm = 60, angle = 0, timer = TempestStroke.DEFAULT_TIMER, startTime = null) {
    super();

    if (typeof config === 'string') {
      if (!has(TempestStroke.library, config)) {
        throw new Error(`No stroke named ${config} found.`);
      }

      config = TempestStroke.library[config]; // eslint-disable-line no-param-reassign
    }

    createConstantProperty(this, 'axes', {});

    Object.keys(config).forEach((axis) => {
      this.#validateNoise(config[axis]);

      createConstantProperty(this.axes, axis, {});

      Object.keys(config[axis]).forEach((property) => {
        createConstantProperty(this.axes[axis], property, config[axis][property]);
      });

      Object.keys(TempestStroke.DEFAULT_PARAMETERS).forEach((property) => {
        if (!has(config[axis], property)) {
          createConstantProperty(this.axes[axis], property, TempestStroke.DEFAULT_PARAMETERS[property]);
        }
      });

      const { from, to } = this.axes[axis];

      createConstantProperty(this.axes[axis], '$current', { from, to });
    });

    this.#angle = angle;
    this.#startAngle = angle;
    this.#bpmProvider = StrokeParameterProvider.createFrom(bpm);
    this.#bpm = this.#bpmProvider.next();
    this.#timer = timer instanceof Function ? {
      now: timer,
    } : timer;
    this.#startTime = startTime;
  }

  * generate (ayva) {
    if (this.#timer) {
      yield* this.#synchronizedGenerate(ayva);
    } else {
      yield* this.#unsynchronizedGenerate(ayva);
    }
  }

  /**
   * Generates moves that will move to the start position of this Tempest Stroke.
   * The speed of the moves default to 1 unit per second.
   *
   * @param {Ayva} ayva
   * @param {Object} [mixin] - configuration options to add or override for each move.
   */
  * start (ayva, mixin) {
    const moves = this.getStartMoves(ayva, mixin);
    yield moves;
  }

  /**
   * Returns an array of moves that will move to the start position of this Tempest Stroke.
   * The speed of the moves default to 1 unit per second.
   *
   * @deprecated since version 0.13.0. Use start() generator instead.
   * @param {Ayva} ayva
   * @param {Object} [mixinConfig] - configuration options to add or override for each move.
   * @returns array of moves
   */
  getStartMoves (ayva, mixinConfig) {
    const speedConfig = {};

    if (!mixinConfig || !(has(mixinConfig, 'speed') || has(mixinConfig, 'duration'))) {
      speedConfig.speed = 1;
    }

    const usedAxesMapByName = {};

    const axesMoves = Object.keys(this.axes).map((axisNameOrAlias) => {
      usedAxesMapByName[ayva.getAxis(axisNameOrAlias).name] = true;
      const params = this.axes[axisNameOrAlias];

      const to = params.motion(
        params.$current.from,
        params.$current.to,
        params.phase,
        params.ecc,
        this.#bpm,
        params.shift + this.#angle
      )({ index: -1, frequency: ayva.frequency });

      return {
        axis: axisNameOrAlias,
        to,
        ...speedConfig,
        ...mixinConfig,
      };
    });

    const unusedAxesMoves = ayva.getAxes()
      .filter((axis) => !usedAxesMapByName[axis.name])
      .map((axis) => {
        const movement = {
          axis: axis.name,
          to: axis.defaultValue,
          ...speedConfig,
          ...mixinConfig,
        };

        if (axis.type === 'boolean') {
          delete movement.speed;
          delete movement.duration;
        }

        return movement;
      });

    return [...axesMoves, ...unusedAxesMoves];
  }

  /**
   * Creates a new TempestStroke that starts with a transition from this TempestStroke.
   *
   * @example
   * const orbit = new TempestStroke('orbit-grind');
   *
   * // Create a transition from an orbit-grind to a 30 BPM vortex-tease that takes 5 seconds.
   * const vortex = orbit.transition('vortex-tease', 30, 5);
   *
   * ayva.do(vortex);
   *
   * @param {Object|String} config - stroke config or name of library config
   * @param {Number|Function} bpm - beats per minute of next stroke (or function that provides bpm)
   * @param {Number} duration - how long the transition should take in seconds
   */
  transition (config, bpm = 60, duration = 1, onTransitionStart = null, onTransitionEnd = null) {
    return new TempestStrokeWithTransition(config, bpm, this, duration, onTransitionStart, onTransitionEnd);
  }

  * #synchronizedGenerate (ayva) {
    this.#startTime = this.#startTime || this.#timer.now();
    const targetAngle = TempestStroke.computeTargetAngle(this.#angle, this.#startAngle);

    while (this.#angle < targetAngle) {
      const time = this.#timer.now() - this.#startTime;
      const bpmFactor = ((time * 2 * Math.PI) / 60);
      const theta = this.#bpm * bpmFactor;
      const originalAngle = this.#angle;
      this.#angle = this.#startAngle + theta;

      this.#setAxisValues(ayva, this.#angle - originalAngle);

      const originalBpm = this.#bpm;
      this.#bpm = this.#bpmProvider.next();

      // Magic maths to make sure bpm changes don't mess up the angle.
      this.#startAngle += (originalBpm - this.#bpm) * bpmFactor;

      yield ayva.period;
    }
  }

  * #unsynchronizedGenerate () {
    const { granularity } = TempestStroke;

    const startAngle = this.#angle;

    for (let i = 0; i < granularity; i++) {
      yield this.#createMoves(i);
      this.#angle = startAngle + (((i + 1) * Math.PI) / granularity);
      this.#bpm = this.#bpmProvider.next();
    }
  }

  #setAxisValues (ayva, angleSlice) {
    const axisValues = {};

    Object.keys(this.axes).forEach((axis) => {
      const params = this.axes[axis];

      axisValues[axis] = params.motion(
        params.$current.from,
        params.$current.to,
        params.phase,
        params.ecc,
        this.#bpm,
        params.shift + this.#angle,
      )({ index: -1, frequency: ayva.frequency });

      this.#generateNoise(params, this.#angle - angleSlice, angleSlice);
    });

    ayva.setValues(axisValues);
  }

  #createMoves () {
    const { granularity } = TempestStroke;
    const moves = Object.keys(this.axes).map((axis) => {
      const params = this.axes[axis];
      const seconds = 30 / granularity;

      const result = {
        axis,
        value: params.motion(
          params.$current.from,
          params.$current.to,
          params.phase,
          params.ecc,
          this.#bpm,
          params.shift + this.#angle,
        ),
        duration: seconds / this.#bpm,
      };

      this.#generateNoise(params, this.#angle, Math.PI / TempestStroke.granularity);

      return result;
    });

    return moves;
  }

  #generateNoise (params, angle, angleSlice) {
    if (params.noise) {
      const { PI } = Math;
      const deg = (radians) => (radians * 180) / PI;
      const getNoise = (which) => (validNumber(params.noise) ? params.noise : params.noise[which] || 0);
      const phaseAngle = (params.phase * PI) / 2;
      const absoluteAngle = phaseAngle + params.shift + angle;

      // We convert the angle to degrees and round so it's asthetically easier to find the transitions.
      const startDegrees = Math.round(deg(absoluteAngle % (PI * 2)));
      const endDegrees = Math.round(startDegrees + deg(angleSlice));
      const movingToStart = startDegrees < 360 && endDegrees >= 360;
      const movingToMid = startDegrees < 180 && endDegrees >= 180;

      if (movingToStart) {
        const noise = getNoise('to');
        const noiseRange = (params.from - params.to) / 2;
        params.$current.to = params.to + noise * noiseRange * Math.random();
      } else if (movingToMid) {
        const noise = getNoise('from');
        const noiseRange = (params.to - params.from) / 2;
        params.$current.from = params.from + noise * noiseRange * Math.random();
      }
    }
  }

  #validateNoise (params) {
    if (has(params, 'noise')) {
      const { noise } = params;
      const error = (value) => {
        throw new Error(`Invalid noise: ${value}`);
      };

      const isObject = typeof noise === 'object';

      if (isObject && has(noise, 'from') && !validNumber(noise.from, 0, 1)) {
        error(noise.from);
      }

      if (isObject && has(noise, 'to') && !validNumber(noise.to, 0, 1)) {
        error(noise.to);
      }

      if (!isObject && !validNumber(noise, 0, 1)) {
        error(noise);
      }
    }
  }
}

class TempestStrokeWithTransition extends TempestStroke {
  #config;

  #transition;

  #onTransitionStart;

  #onTransitionEnd;

  constructor (config, bpmProvider, source, duration, onTransitionStart, onTransitionEnd) {
    super(config, bpmProvider, 0, source.timer, source.startTime);
    this.angle = TempestStrokeTransition.computeTransitionStartAngle(source, duration, this.bpm);

    this.startAngle = this.angle;

    // Magic maths to make sure new stroke's start time meshes well with the
    // new start angle... <3
    const elapsedRadians = source.angle - source.startAngle;
    const elapsed = elapsedRadians / ((source.bpm * 2 * Math.PI) / 60);

    this.startTime += elapsed + duration;
    this.#transition = new TempestStrokeTransition(source, this, duration);
    this.#config = config;
    this.#onTransitionStart = onTransitionStart;
    this.#onTransitionEnd = onTransitionEnd;

    if (source.ayva) {
      this.bind(source.ayva);
      this.#transition.bind(source.ayva);
    }
  }

  * generate (ayva) {
    if (!this.#transition.complete) {
      if (this.#onTransitionStart instanceof Function) {
        this.#onTransitionStart(this.#transition.duration, this.bpm);
      }

      yield* this.#transition();

      if (this.#onTransitionEnd instanceof Function) {
        this.#onTransitionEnd(this.#config, this.bpm);
      }
    }

    yield* super.generate(ayva);
  }
}

class TempestStrokeTransition extends GeneratorBehavior {
  #source;

  #target;

  #duration;

  get duration () {
    return this.#duration;
  }

  constructor (sourceBehavior, targetBehavior, duration) {
    super();
    this.#source = sourceBehavior;
    this.#target = targetBehavior;
    this.#duration = duration;
  }

  * generate (ayva) {
    if (!(ayva instanceof Ayva)) {
      throw new TypeError(`Invalid Ayva instance: ${ayva}`);
    }

    const zeroParamsLinearRotation = {
      ...TempestStroke.DEFAULT_PARAMETERS,
      from: 0.5,
      to: 0.5,
      $current: {
        from: 0.5,
        to: 0.5,
      },
    };

    const zeroParamsAux = {
      ...TempestStroke.DEFAULT_PARAMETERS,
      from: 0,
      to: 0,
      $current: {
        from: 0,
        to: 0,
      },
    };

    const sourceAxes = this.#getAxisMapByName(this.#source.axes, ayva);
    const targetAxes = this.#getAxisMapByName(this.#target.axes, ayva);

    const transitionAxisMoves = {};

    Object.keys(targetAxes).forEach((axis) => {
      const zeroParams = ayva.getAxis(axis).type === 'auxiliary' ? zeroParamsAux : zeroParamsLinearRotation;

      const sourceAxis = sourceAxes[axis] ?? { ...zeroParams };
      const targetAxis = targetAxes[axis];

      transitionAxisMoves[axis] = this.#createTransitionAxisMove(sourceAxis, targetAxis);
    });

    // Catch any dangling axes that were part of source but not part of target.
    Object.keys(sourceAxes).forEach((axis) => {
      if (!transitionAxisMoves[axis]) {
        const zeroParams = ayva.getAxis(axis).type === 'auxiliary' ? zeroParamsAux : zeroParamsLinearRotation;

        const sourceAxis = sourceAxes[axis];
        const targetAxis = { ...zeroParams };

        transitionAxisMoves[axis] = this.#createTransitionAxisMove(sourceAxis, targetAxis);
      }
    });

    const moves = [];
    Object.keys(transitionAxisMoves).forEach((axis) => {
      moves.push({
        axis,
        ...transitionAxisMoves[axis],
      });
    });

    yield moves;
    this.complete = true;
  }

  #createTransitionAxisMove (sourceAxis, targetAxis) {
    const sourceBpm = this.#source.bpm;
    const averageBpm = (this.#source.bpm + this.#target.bpm) / 2;

    return {
      value: (params) => {
        const { x } = params;

        const from = Ayva.map(x, 0, 1, sourceAxis.$current.from, targetAxis.$current.from);
        const to = Ayva.map(x, 0, 1, sourceAxis.$current.to, targetAxis.$current.to);
        const phase = Ayva.map(x, 0, 1, sourceAxis.phase, targetAxis.phase);
        const ecc = Ayva.map(x, 0, 1, sourceAxis.ecc, targetAxis.ecc);
        const bpm = Ayva.map(x, 0, 1, sourceBpm, averageBpm);

        const provider = Ayva.blendMotion(
          sourceAxis.motion(from, to, phase, ecc, bpm, this.#source.angle),
          targetAxis.motion(from, to, phase, ecc, bpm, this.#source.angle),
          x
        );

        return provider(params);
      },
      duration: this.#duration,
    };
  }

  #getAxisMapByName (axes, ayva) {
    // Convert axes config to be by name instead of alias so it is easier to reason about.
    return Object.keys(axes).reduce((map, axis) => {
      const axisConfig = ayva.getAxis(axis);
      map[axisConfig.name] = axes[axis];
      return map;
    }, {});
  }

  static computeTransitionStartAngle (source, duration, targetBpm) {
    const averageBpm = (source.bpm + targetBpm) / 2;
    return source.angle + (Math.PI * 2 * (averageBpm / 60) * duration);
  }
}

export default TempestStroke;