tuneflow
Version:
Programmable, extensible music composition & arrangement
518 lines (467 loc) • 15.8 kB
text/typescript
import _ from 'underscore';
import { ge as greaterEqual, gt as greaterThan, lt as lowerThan } from 'binary-search-bounds';
export enum AutomationTargetType {
UNDEFINED = 0,
VOLUME = 1,
PAN = 2,
AUDIO_PLUGIN = 3,
}
/**
* The target of an automation.
*
* i.e. Volume, Pan, a param of an audio plugin, etc.
*/
export class AutomationTarget {
private type: AutomationTargetType;
private pluginInstanceId?: string;
private paramId?: string;
/**
*
* @param type Type of the automation target.
* @param pluginInstanceId The instance id of the plugin, required if type is AUDIO_PLUGIN.
* @param paramId The paramId of the plugin automation param, required if type is AUDIO_PLUGIN.
*/
constructor(type: AutomationTargetType, pluginInstanceId?: string, paramId?: string) {
this.type = type;
this.pluginInstanceId = pluginInstanceId;
this.paramId = paramId;
}
/** Gets the type of the target. */
getType() {
return this.type;
}
setType(type: AutomationTargetType) {
this.type = type;
}
/**
* The local id of the target track plugin.
*
* Available if type is `AUDIO_PLUGIN`.
*/
getPluginInstanceId() {
return this.pluginInstanceId;
}
/**
*
* @param pluginInstanceId The instance id of the track plugin that can be retrieved from `AudioPlugin.getInstanceId`.
*/
setPluginInstanceId(pluginInstanceId?: string) {
this.pluginInstanceId = pluginInstanceId;
}
/**
* The paramId of the plugin automation param.
*
* Available if type is `AUDIO_PLUGIN`.
*/
getParamId() {
return this.paramId;
}
setParamId(paramId?: string) {
this.paramId = paramId;
}
equals(target: AutomationTarget) {
return AutomationTarget.areAutomationTargetsEqual(
this.getType(),
target.getType(),
this.getPluginInstanceId(),
target.getPluginInstanceId(),
this.getParamId(),
target.getParamId(),
);
}
clone() {
return new AutomationTarget(this.type, this.pluginInstanceId, this.paramId);
}
/** Gets a unique id that identifies this target type. */
toTfAutomationTargetId() {
return AutomationTarget.encodeAutomationTarget(this.type, this.pluginInstanceId, this.paramId);
}
static fromTfAutomationTargetId(tfAutomationTargetId: string) {
return AutomationTarget.decodeAutomationTarget(tfAutomationTargetId);
}
static encodeAutomationTarget(
targetType: AutomationTargetType,
pluginInstanceId?: string,
paramId?: string,
) {
if (targetType === AutomationTargetType.AUDIO_PLUGIN) {
return `${targetType}^^${pluginInstanceId}^^${paramId}`;
}
return `${targetType}`;
}
static decodeAutomationTarget(encodedTarget: string) {
const parts = encodedTarget.split('^^');
if (parts.length === 0) {
throw new Error(`Invalid automation target id: ${encodedTarget}`);
}
const type = Number(parts[0]);
if (parts.length > 2) {
return new AutomationTarget(type, parts[1], parts[2]);
}
return new AutomationTarget(type);
}
static areAutomationTargetsEqual(
targetType1: AutomationTargetType,
targetType2: AutomationTargetType,
pluginInstanceId1?: string,
pluginInstanceId2?: string,
paramId1?: string,
paramId2?: string,
) {
return (
AutomationTarget.encodeAutomationTarget(targetType1, pluginInstanceId1, paramId1) ===
AutomationTarget.encodeAutomationTarget(targetType2, pluginInstanceId2, paramId2)
);
}
}
/** A single point in an automation curve. */
export interface AutomationPoint {
/** An int32. */
id: number;
tick: number;
value: number;
}
/** The points and settings of an automation param. */
export class AutomationValue {
private points: AutomationPoint[] = [];
private disabled = false;
private nextPointIdInternal = 1;
getDisabled() {
return this.disabled;
}
setDisabled(isDisabled: boolean) {
this.disabled = isDisabled;
}
getPoints() {
return this.points;
}
getPointsInRange(startTick: number, endTick: number) {
return AutomationValue.getPointsInRangeImpl(this.points, startTick, endTick);
}
private static getPointsInRangeImpl(
points: AutomationPoint[],
startTick: number,
endTick: number,
) {
const startIndex = greaterEqual(
points,
{ tick: startTick } as AutomationPoint,
(a, b) => a.tick - b.tick,
);
const results = [];
for (let i = startIndex; i < points.length; i += 1) {
const point = points[i];
if (point.tick > endTick) {
break;
}
results.push(point);
}
return results;
}
/**
*
* @param overwrite Whether to overwrite the points at the insert tick.
*/
addPoint(tick: number, value: number, overwrite = false) {
const newPoint: AutomationPoint = {
tick,
value: Math.max(0, Math.min(1, value)),
id: this.getNextPointIdInternal(),
};
AutomationValue.orderedInsertPointInternal(this.points, newPoint, overwrite);
return newPoint;
}
/**
* Remove points that match the given ids.
* @param pointIds
*/
removePoints(pointIds: number[]) {
const idSet = new Set<number>(pointIds);
for (let i = this.points.length - 1; i >= 0; i -= 1) {
const point = this.points[i];
if (idSet.has(point.id)) {
this.points.splice(i, 1);
}
}
}
/**
* Removes all points within the given time range.
* @param startTick Inclusive
* @param endTick Inclusive
*/
removePointsInRange(startTick: number, endTick: number) {
const startIndex = greaterEqual(
this.points,
{ tick: startTick } as AutomationPoint,
(a, b) => a.tick - b.tick,
);
if (startIndex >= this.points.length) {
return;
}
let endIndex = startIndex;
while (endIndex + 1 < this.points.length && this.points[endIndex + 1].tick <= endTick) {
endIndex += 1;
}
this.points.splice(startIndex, endIndex - startIndex + 1);
}
movePointsInRange(
startTick: number,
endTick: number,
offsetTick: number,
offsetValue: number,
overwriteValuesInDragArea = true,
) {
const points = this.getPointsInRange(startTick, endTick);
this.movePoints(
points.map(point => point.id),
offsetTick,
offsetValue,
overwriteValuesInDragArea,
);
}
moveAllPoints(offsetTick: number, offsetValue: number, overwriteValuesInDragArea = true) {
this.movePoints(
this.points.map(point => point.id),
offsetTick,
offsetValue,
overwriteValuesInDragArea,
);
}
/**
*
* @param pointIds
* @param offsetTick
* @param offsetValue
* @param overwriteValuesInDragArea If true, all values in between the moved points' old and new indexes will be removed.
*/
movePoints(
pointIds: number[],
offsetTick: number,
offsetValue: number,
overwriteValuesInDragArea = true,
) {
if (pointIds.length === 0) {
return;
}
const pointIdSet = new Set<number>(pointIds);
let dragAreaLeftIndex: number | undefined = undefined;
let dragAreaRightIndex: number | undefined = undefined;
const selectedPoints = [];
for (let i = 0; i < this.points.length; i += 1) {
const point = this.points[i];
if (!pointIdSet.has(point.id)) {
continue;
}
selectedPoints.push(point);
if (dragAreaLeftIndex === undefined) {
dragAreaLeftIndex = i;
}
dragAreaRightIndex = i;
}
if (dragAreaLeftIndex === undefined || dragAreaRightIndex === undefined) {
// None of the given points are not in the automation.
return;
}
if (overwriteValuesInDragArea) {
// Remove values in drag area.
if (offsetTick < 0) {
// Move left, remove values to the left.
const selectedPointsLeftAfterMove = Math.max(
0,
this.points[dragAreaLeftIndex].tick + offsetTick,
);
const startRemoveIndex = greaterThan(
this.points,
{ tick: selectedPointsLeftAfterMove } as AutomationPoint,
(a, b) => a.tick - b.tick,
);
if (startRemoveIndex < dragAreaLeftIndex) {
this.points.splice(startRemoveIndex, dragAreaLeftIndex - startRemoveIndex);
}
} else if (offsetTick > 0) {
// Move right, remove values to the right.
const selectedPointsRightAfterMove = this.points[dragAreaRightIndex].tick + offsetTick;
const endRemoveIndex = lowerThan(
this.points,
{ tick: selectedPointsRightAfterMove } as AutomationPoint,
(a, b) => a.tick - b.tick,
);
if (endRemoveIndex > dragAreaRightIndex) {
this.points.splice(dragAreaRightIndex + 1, endRemoveIndex - dragAreaRightIndex);
}
}
}
for (const point of selectedPoints) {
point.tick = Math.max(0, point.tick + offsetTick);
point.value = Math.max(0, Math.min(1, point.value + offsetValue));
}
// Maintain the order of points.
if (Math.abs(offsetTick) > 0) {
this.points.sort((a, b) => a.tick - b.tick);
}
}
clone() {
const newAutomationValue = new AutomationValue();
newAutomationValue.setDisabled(this.disabled);
for (const point of this.points) {
newAutomationValue.addPoint(point.tick, point.value, /* overwrite= */ false);
}
return newAutomationValue;
}
private getNextPointIdInternal() {
const pointId = this.nextPointIdInternal;
if (this.nextPointIdInternal >= /* 2^31 -1 */ 2147483647) {
this.nextPointIdInternal = 1;
} else {
this.nextPointIdInternal += 1;
}
return pointId;
}
private static orderedInsertPointInternal(
points: AutomationPoint[],
newPoint: AutomationPoint,
overwrite = false,
) {
const insertIndex = greaterEqual(
points,
newPoint,
(a: AutomationPoint, b: AutomationPoint) => a.tick - b.tick,
);
while (overwrite && points[insertIndex] && points[insertIndex].tick === newPoint.tick) {
// Remove the points at the insert tick.
points.splice(insertIndex, 1);
}
points.splice(insertIndex, 0, newPoint);
}
}
/**
* All automation data of one entity (such as a track).
*
* Each `AutomationData` consists of several automation targets(`AutomationTarget`) and
* the values(`AutomationValue`) store in unique targets.
*
* Note that there can be duplicate targets, but targets of the same type write to
* and read from the same automation value.
*
* For example, there can be multiple Volume targets, each Volume target corresponds to
* the same automation value.
*/
export class AutomationData {
private targets: AutomationTarget[] = [];
private targetValues: { [tfAutomationTargetId: string]: AutomationValue | undefined } = {};
/** All automation targets specified by the user. */
getAutomationTargets() {
return this.targets;
}
/** Values of each unique automation target. */
getAutomationTargetValues() {
return this.targetValues;
}
/**
*
* @param tfAutomationTargetId The targetId that can be retrieved from `AutomationTarget.prototype.toTfAutomationTargetId` or `AutomationTarget.encodeAutomationTarget`.
* @returns The automation value of the given target if exists, otherwise creates a new one and returns it.
*/
getOrCreateAutomationValueById(tfAutomationTargetId: string): AutomationValue {
if (!this.targetValues[tfAutomationTargetId]) {
this.targetValues[tfAutomationTargetId] = new AutomationValue();
}
return this.targetValues[tfAutomationTargetId] as AutomationValue;
}
/**
*
* Gets or creates the automation points and settings of an automation target.
* @param tfAutomationTargetId The targetId that can be retrieved from `AutomationTarget.prototype.toTfAutomationTargetId` or `AutomationTarget.encodeAutomationTarget`.
*/
getAutomationValueById(tfAutomationTargetId: string) {
return this.targetValues[tfAutomationTargetId];
}
getAutomationValueByTarget(target: AutomationTarget) {
const tfAutomationTargetId = target.toTfAutomationTargetId();
return this.getAutomationValueById(tfAutomationTargetId);
}
/** Adds an automation target, if there was no such target, creates the automation value. */
addAutomation(target: AutomationTarget, index = 0) {
if (!_.isNumber(index)) {
index = 0;
}
this.targets.splice(index, 0, target);
const tfAutomationTargetId = target.toTfAutomationTargetId();
if (!this.getAutomationValueById(tfAutomationTargetId)) {
this.targetValues[tfAutomationTargetId] = new AutomationValue();
}
}
/** Removes all automation targets of the given type and its automation value. */
removeAutomation(target: AutomationTarget) {
// Remove targets.
for (let i = this.targets.length - 1; i >= 0; i -= 1) {
const existingTarget = this.targets[i];
if (existingTarget.equals(target)) {
this.targets.splice(i, 1);
}
}
// Remove values.
const tfAutomationTargetId = target.toTfAutomationTargetId();
delete this.targetValues[tfAutomationTargetId];
}
/** Remove all automations associated with a certain plugin. */
removeAutomationOfPlugin(pluginInstanceId: string) {
for (let i = this.targets.length - 1; i >= 0; i -= 1) {
const automationTarget = this.targets[i];
if (automationTarget.getPluginInstanceId() === pluginInstanceId) {
this.removeAutomation(automationTarget);
}
}
for (const tfAutomationTargetId of _.keys(this.targetValues)) {
const automationTarget = AutomationTarget.decodeAutomationTarget(tfAutomationTargetId);
if (automationTarget.getPluginInstanceId() === pluginInstanceId) {
this.removeAutomation(automationTarget);
}
}
}
/**
*
* @param startTick Inclusive
* @param endTick Inclusive
*/
removeAllPointsWithinRange(startTick: number, endTick: number) {
for (const tfAutomationTargetId of _.keys(this.targetValues)) {
const automationValue = this.targetValues[tfAutomationTargetId] as AutomationValue;
automationValue.removePointsInRange(startTick, endTick);
}
}
/**
*
* @param startTick Inclusive
* @param endTick Inclusive
*/
moveAllPointsWithinRange(
startTick: number,
endTick: number,
offsetTick: number,
offsetValue: number,
) {
for (const tfAutomationTargetId of _.keys(this.targetValues)) {
const automationValue = this.targetValues[tfAutomationTargetId] as AutomationValue;
automationValue.movePointsInRange(
startTick,
endTick,
offsetTick,
offsetValue,
/* overwriteValuesInDragArea= */ false,
);
}
}
/** Creates a clone of this automation data. */
clone() {
const newAutomationData = new AutomationData();
for (const target of this.targets) {
newAutomationData.addAutomation(target.clone());
}
for (const tfAutomationTargetId of _.keys(this.targetValues)) {
const targetValue = this.targetValues[tfAutomationTargetId] as AutomationValue;
newAutomationData.targetValues[tfAutomationTargetId] = targetValue.clone();
}
return newAutomationData;
}
}