tuneflow
Version:
Programmable, extensible music composition & arrangement
272 lines (239 loc) • 8.49 kB
text/typescript
import * as _ from 'underscore';
import type { ReadAPIs, TuneflowPlugin } from './base_plugin';
import type { Song } from './models/song';
export class TuneflowPipeline {
private plugins: TuneflowPlugin[] = [];
/** Whether the last run encountered errors. */
private threwErrorInLastRun = false;
private maxNumPluginsToKeep = 50;
private originalSong?: Song;
private activePluginIndex = -1;
// @ts-ignore
private static cloneSongFnInternal: (song: Song) => Promise<Song>;
// @ts-ignore
private static materializeSongFnInternal: (song: Song, songId: string) => Promise<void>;
/** Provide additional APIs for plugins to read required data, e.g. read audio file content. */
// @ts-ignore
private static readApisInternal: ReadAPIs;
/**
* Adds a plugin as the active plugin.
*
* * If the active plugin is rollbackable, adds the plugin after it.
* * If the active plugin is not rollbackable, removes the unrollbackable plugin and add the new
* plugin at its position.
*
* Removes all the plugins after the insert position.
* @return The insert position of the plugin.
*/
addAsOrReplaceActivePlugin(plugin: TuneflowPlugin) {
let insertIndex: number;
if (this.activePluginIndex <= -1) {
insertIndex = 0;
} else {
// @ts-ignore
if (this.plugins[this.activePluginIndex].isRollbackable) {
// The active plugin is rollbackable, insert the new plugin after it.
insertIndex = this.activePluginIndex + 1;
} else {
// The active plugin is not rollbackable, replace it with the new plugin.
insertIndex = this.activePluginIndex;
}
}
// Remove all plugins starting from the insert index.
if (this.plugins.length > 0) {
this.plugins.splice(insertIndex, this.plugins.length - insertIndex);
}
// Add the new plugin at insert index.
this.addPluginAt(plugin, insertIndex);
// Find the insert position as the plugin list might have changed due to length maintainence.
return this.getPluginIndexByPluginInstanceId(plugin.instanceId);
}
/** Gets all plugins in execution order. */
getPlugins(): TuneflowPlugin[] {
return this.plugins;
}
resetCache() {
for (const plugin of this.plugins) {
// @ts-ignore
delete plugin.songCacheInternal;
}
}
setOriginalSong(originalSong: Song) {
this.originalSong = originalSong;
}
hasOriginalSong() {
return !!this.originalSong;
}
/**
* Non-cancellable part of the pipeline run, which can modify the pipeline.
*
* @param dirtyIndex
* @returns
*/
async prepareRun(dirtyIndex = 0) {
console.log(`dirty from: ${dirtyIndex}`);
if (!this.originalSong) {
this.threwErrorInLastRun = true;
return null;
}
dirtyIndex = Math.max(0, dirtyIndex);
this.setActivePluginIndex(dirtyIndex);
this.threwErrorInLastRun = false;
// Jump to the latest cached song before dirtyIndex if available.
const cachedPluginIndex = this.getIndexOfLatestPluginWithCacheBeforeIndex(dirtyIndex);
const inputSong = await this.cloneCachedSongAtPluginIndex(cachedPluginIndex);
// Clear plugin cache from dirtyIndex since we will recompute.
for (let i = dirtyIndex; i < this.plugins.length; i += 1) {
// @ts-ignore
delete this.plugins[i].songCacheInternal;
}
for (let i = 0; i < this.plugins.length; i += 1) {
// Mark all plugins to be rerun as unrollbackable.
// @ts-ignore
this.plugins[i].isRollbackable = i <= cachedPluginIndex;
}
return {
inputSong,
plugins: this.plugins.slice(cachedPluginIndex >= 0 ? cachedPluginIndex + 1 : 0),
};
}
/**
* Runs a plugin.
*
* This is supposed to NOT make any pipeline change and should
* ONLY modify plugins and the input song, as the latter will all be discarded
* if this run is cancelled.
*
* TODO: Add timeout.
*
* @returns If successful, returns the updated song instance. Otherwise return null.
*/
static async run(inputSong: Song, plugins: TuneflowPlugin[], songId: string) {
// Run dirty plugins.
let numFinishedPlugins = 0;
for (const plugin of plugins) {
// TODO: Revisit here to see if we need to set isRollbackable to false.
// @ts-ignore
if (!TuneflowPipeline.isPluginRunnable(plugin)) {
return inputSong;
}
// @ts-ignore
inputSong.setPluginContextInternal(plugin);
try {
// @ts-ignore
plugin.isExecuting = true;
plugin.setProgress(null);
await plugin.run(inputSong, plugin.getParamsInternal(), TuneflowPipeline.readApisInternal);
// @ts-ignore
inputSong.clearPluginContextInternal();
// Write temporary buffers to local files.
await TuneflowPipeline.materializeSongFnInternal(inputSong, songId);
// @ts-ignore
plugin.isExecuting = false;
} catch (e: any) {
// @ts-ignore
inputSong.clearPluginContextInternal();
// @ts-ignore
plugin.isExecuting = false;
throw e;
}
// @ts-ignore
plugin.songCacheInternal = await TuneflowPipeline.cloneSongFnInternal(inputSong);
// @ts-ignore
plugin.isRollbackable = true;
numFinishedPlugins += 1;
}
console.log(`Number of successfully finished plugins: ${numFinishedPlugins}`);
return inputSong;
}
/**
* Activate an already executed plugin.
*
* This will not affect plugins later than this plugin
* since we should be able to rollforward.
*/
restoreCachedPlugin(index: number) {
this.setActivePluginIndex(index);
return this.cloneCachedSongAtPluginIndex(index);
}
getActivePluginIndex() {
return this.activePluginIndex;
}
private setActivePluginIndex(index: number) {
console.log('current active plugin index', index);
this.activePluginIndex = index;
}
private async cloneCachedSongAtPluginIndex(index: number) {
if (index >= 0) {
// @ts-ignore
return await TuneflowPipeline.cloneSongFnInternal(
// @ts-ignore
this.plugins[index].songCacheInternal as Song,
);
} else {
if (!this.originalSong) {
throw new Error('Original song is not avaiable to clone.');
}
return await TuneflowPipeline.cloneSongFnInternal(this.originalSong);
}
}
/**
* Resets the pipeline.
*/
reset() {
this.plugins.splice(0, this.plugins.length);
this.originalSong = undefined;
this.activePluginIndex = -1;
this.threwErrorInLastRun = false;
}
isPluginFunctioning(plugin: TuneflowPlugin) {
// @ts-ignore
return !!plugin.songCacheInternal;
}
getPluginIndexByPluginInstanceId(instanceId: string) {
return _.findIndex(this.plugins, plugin => plugin.instanceId === instanceId);
}
getThrewErrorInLastRun() {
return this.threwErrorInLastRun;
}
getPluginCache(plugin: TuneflowPlugin) {
// @ts-ignore
return plugin.songCacheInternal;
}
/**
* Sets the maximum number of plugins to keep in the pipeline.
*
* The oldest plugins will be removed to keep the plugins within limit.
*
* @param maxNumPluginsToKeep
*/
setMaxNumPluginsToKeep(maxNumPluginsToKeep: number) {
this.maxNumPluginsToKeep = maxNumPluginsToKeep;
this.maintainPluginListSize();
}
/**
* Searches from (index - 1) to 0 and return the index of the first plugin found with cache.
* @param index
* @returns
*/
private getIndexOfLatestPluginWithCacheBeforeIndex(index: number) {
for (let lastIndexWithCache = index - 1; lastIndexWithCache >= 0; lastIndexWithCache -= 1) {
if (!!this.getPluginCache(this.plugins[lastIndexWithCache])) {
return lastIndexWithCache;
}
}
return -1;
}
private maintainPluginListSize() {
while (this.plugins.length > this.maxNumPluginsToKeep && this.plugins.length > 0) {
this.plugins.shift();
}
}
private addPluginAt(plugin: TuneflowPlugin, index: number) {
this.plugins.splice(index, 0, plugin);
this.maintainPluginListSize();
}
static isPluginRunnable(plugin: TuneflowPlugin) {
return plugin.enabledInternal && plugin.hasAllParamsSet();
}
}