UNPKG

tuneflow

Version:

Programmable, extensible music composition & arrangement

1,054 lines (956 loc) 25.5 kB
import { nanoid } from 'nanoid'; import { ge as greaterEqual, lt as lowerThan, le as lowerEqual } from 'binary-search-bounds'; import { AudioPlugin } from './audio_plugin'; import type { Song } from './song'; import { Clip, ClipType } from './clip'; import type { AudioClipData } from './clip'; import { dbToVolumeValue, decodeAudioPluginTuneflowId } from '../utils'; import { AutomationData } from './automation'; import _ from 'underscore'; export enum TrackType { MIDI_TRACK = 1, AUDIO_TRACK = 2, MASTER_TRACK = 3, AUX_TRACK = 4, } /** * A track in the song that maps to an instrument. * * It contains clips, instrument information, play status(volume, muted, etc.), and more. */ export class Track { static MAX_NUM_EFFECTS_PLUGINS = 5; static MAX_NUM_SENDS = 5; private insturment?: InstrumentInfo; /** Clips sorted by their start tick. */ private clips: Clip[]; private suggestedInstruments: InstrumentInfo[]; private uuid: string; private volume: number; private solo: boolean; private muted: boolean; private rank: number; private pan: number; private samplerPlugin?: AudioPlugin; private audioPlugins: { [index: string]: AudioPlugin } = {}; private song: Song; private automation: AutomationData; private type: TrackType; private auxTrackData?: AuxTrackData; private sends: { [index: string]: TrackSend } = {}; /** If not specified, the track outputs to the default device. */ private output?: TrackOutput; /** * IMPORTANT: Do not use the constructor directly, call * createTrack from a song instead. */ constructor({ type, song, uuid = Track.generateTrackIdInternal(), clips = [], instrument, suggestedInstruments = [], volume = dbToVolumeValue(0), solo = false, muted = false, rank = 0, pan = 0, }: { type: TrackType; song: Song; /** * The universal-unique identifier of the track. * * In most cases, leave it blank and it will be automatically assigned. */ uuid?: string; /** Clips of the track. */ clips?: Clip[]; /** Information about the instrument to play this track. */ instrument?: InstrumentInfo; /** Other possible instruments. */ suggestedInstruments?: InstrumentInfo[]; /** A float value indicating the track-level volume, ranging from 0 to 1. */ volume?: number; /** Whether this track is in solo mode. */ solo?: boolean; /** Whether this track is muted. */ muted?: boolean; /** The rank of this track within the song. */ rank?: number; /** An integer value from -64 to 63, corresponding to the midi pan CC 0 - 127. */ pan?: number; }) { this.song = song; this.type = type; if (instrument) { this.insturment = instrument; } else if (type === TrackType.MIDI_TRACK) { this.insturment = new InstrumentInfo({ program: 0, isDrum: false, }); } if (type === TrackType.AUX_TRACK) { this.auxTrackData = new AuxTrackData(); this.auxTrackData.setInputBusRank(1); } this.clips = [...clips]; this.suggestedInstruments = [...suggestedInstruments]; this.uuid = uuid; this.volume = volume; this.solo = solo; this.muted = muted; this.rank = rank; this.pan = pan; this.automation = new AutomationData(); } getType() { return this.type; } getSong() { return this.song; } getInstrument() { return this.insturment; } setInstrument({ program, isDrum, }: { /** * General MIDI program number(counting from 0, i.e. "Acoustic Grand Piano" === 0). * * https://www.midi.org/specifications-old/item/gm-level-1-sound-set */ program: number; /** * Whether this instrument is a percussion instrument * (or using channel 9(counting from 0) if you know what it means). */ isDrum: boolean; }) { if (this.type !== TrackType.MIDI_TRACK) { return; } this.insturment = new InstrumentInfo({ program, isDrum }); } getSuggestedInstruments() { return this.suggestedInstruments; } /** * Adds a suggested instrument and returns it. * @returns */ createSuggestedInstrument({ program, isDrum, }: { /** * General MIDI program number(counting from 0, i.e. "Acoustic Grand Piano" === 0). * * https://www.midi.org/specifications-old/item/gm-level-1-sound-set */ program: number; /** * Whether this instrument is a percussion instrument * (or using channel 9(counting from 0) if you know what it means). */ isDrum: boolean; }) { if (this.type !== TrackType.MIDI_TRACK) { return; } const instrumentInfo = new InstrumentInfo({ program, isDrum }); this.suggestedInstruments.push(instrumentInfo); return instrumentInfo; } clearSuggestedInstruments() { this.suggestedInstruments = []; } getId() { return this.uuid; } /** * In most cases, you don't need to use this method and just let the pipeline assign an id for the track. * @param uuid A universally unique id for the track. */ setId(uuid: string) { this.uuid = uuid; } getVolume() { return this.volume; } /** * * @param volume A float value indicating the track-level volume, ranging from 0 to 1. */ setVolume(volume: number) { this.volume = volume; } /** * * @param pan An integer value between -64 and 63. Setting to 0 means balanced. */ setPan(pan: number) { this.pan = pan; } getPan() { return this.pan; } getSolo() { return this.solo; } /** * If set to true, track will be solo'ed and unmuted. */ setSolo(solo: boolean) { this.solo = solo; if (solo && this.muted) { this.muted = false; } } getMuted() { return this.muted; } setMuted(muted: boolean) { this.muted = muted; } getRank() { return this.rank; } createAudioPlugin(tfId: string) { const pluginInfo = decodeAudioPluginTuneflowId(tfId); const plugin = new AudioPlugin( pluginInfo.name, pluginInfo.manufacturerName, pluginInfo.pluginFormatName, pluginInfo.pluginVersion, ); return plugin; } getSamplerPlugin() { return this.samplerPlugin; } /** * * @param plugin * @param clearAutomation Whether to remove existing track automation associated with the old plugin. */ setSamplerPlugin(plugin: AudioPlugin, clearAutomation = true) { if (this.type !== TrackType.MIDI_TRACK) { return; } const pluginTypeChanged = (!this.samplerPlugin && !!plugin) || (!plugin && !!this.samplerPlugin) || (!!plugin && !!this.samplerPlugin && !plugin.matchesTfId(this.samplerPlugin.getTuneflowId())); const oldPlugin = this.samplerPlugin; this.samplerPlugin = plugin; if (pluginTypeChanged && oldPlugin && clearAutomation) { this.automation.removeAutomationOfPlugin(oldPlugin.getInstanceId()); } } /** * This is the number of specified fx plugins. * * Do not use it to loop through the audio plugins map. */ getAudioPluginCount() { return _.keys(this.audioPlugins).length; } /** * * @param index Index of the audio plugin (excluding the sampler plugin), counting from 0. * @returns */ getAudioPluginAt(index: number): AudioPlugin | undefined { return this.audioPlugins[index]; } /** * * @param index Index of the audio plugin (excluding the sampler plugin), counting from 0. * @param plugin */ setAudioPluginAt(index: number, plugin: AudioPlugin, clearAutomation = true) { if (index > Track.MAX_NUM_EFFECTS_PLUGINS - 1) { throw new Error( `The maximum number of effects plugin per track is ${Track.MAX_NUM_EFFECTS_PLUGINS}`, ); } const oldPlugin = this.audioPlugins ? this.audioPlugins[index] : undefined; const pluginTypeChanged = (!oldPlugin && !!plugin) || (!plugin && !!oldPlugin) || (!!plugin && !!oldPlugin && !plugin.matchesTfId(oldPlugin.getTuneflowId())); this.audioPlugins[index] = plugin; if (pluginTypeChanged && oldPlugin && clearAutomation) { this.automation.removeAutomationOfPlugin(oldPlugin.getInstanceId()); } } /** * * @param index Index of the audio plugin (excluding the sampler plugin), counting from 0. */ removeAudioPluginAt(index: number) { const oldPlugin = this.audioPlugins ? this.audioPlugins[index] : undefined; delete this.audioPlugins[index]; if (oldPlugin) { this.automation.removeAutomationOfPlugin(oldPlugin.getInstanceId()); } } getPluginByInstanceId(pluginInstanceId: string) { if (this.samplerPlugin && this.samplerPlugin.getInstanceId() === pluginInstanceId) { return this.samplerPlugin; } if (this.audioPlugins) { for (const index of _.keys(this.audioPlugins)) { const audioPlugin = this.audioPlugins[index]; if (!audioPlugin) { continue; } if (audioPlugin.getInstanceId() === pluginInstanceId) { return audioPlugin; } } } return null; } getTrackStartTick() { if (!this.clips || this.clips.length === 0) { return 0; } return this.clips[0].getClipStartTick(); } getTrackEndTick() { if (!this.clips || this.clips.length === 0) { return 0; } return this.clips[this.clips.length - 1].getClipEndTick(); } getClipById(clipId: string) { for (const clip of this.clips) { if (clip.getId() === clipId) { return clip; } } return null; } getClips() { return this.clips; } /** * Gets the clips whose range overlaps with the given range. */ getClipsOverlappingWith(startTick: number, endTick: number) { const overlappingClips: Clip[] = []; const startIndex = lowerThan( this.clips, { getClipStartTick: () => startTick } as any, (a: Clip, b: Clip) => a.getClipStartTick() - b.getClipStartTick(), ); for (let i = Math.max(startIndex, 0); i < this.clips.length; i += 1) { const currentClip = this.clips[i]; if (currentClip.getClipEndTick() < startTick) { continue; } if (currentClip.getClipStartTick() > endTick) { break; } overlappingClips.push(currentClip); } return overlappingClips; } /** Creates a MIDI clip and optionally inserts it into the track. */ createMIDIClip({ clipStartTick, clipEndTick = undefined, insertClip = true, }: { /** * The start of the clip, must be specified. */ clipStartTick: number; clipEndTick?: number; /** Whether to insert the created clip into the track. */ insertClip?: boolean; }) { if (!_.isNumber(clipStartTick)) { throw new Error('clipStartTick must be specified when creating a clip.'); } const newClipEndTick = clipEndTick === undefined || clipEndTick === null ? clipStartTick + 1 : clipEndTick; if (newClipEndTick < clipStartTick) { throw new Error( `clipEndTick must be greater or equal to clipStartTick, got clipStartTick: ${clipStartTick}, clipEndTick: ${clipEndTick}`, ); } const clip = new Clip({ // @ts-ignore id: Clip.generateClipIdInternal(), type: ClipType.MIDI_CLIP, song: this.song, track: undefined, clipStartTick, clipEndTick: newClipEndTick, }); if (insertClip) { this.insertClip(clip); } return clip; } /** Creates an audio clip and optionally inserts it into the track. */ createAudioClip({ clipStartTick, audioClipData, clipEndTick, insertClip = true, }: { /** * The start of the clip, must be specified. */ clipStartTick: number; /** The audio-related data, required if type is AUDIO_CLIP. */ audioClipData: AudioClipData; clipEndTick?: number; /** Whether to insert the created clip into the track. */ insertClip?: boolean; }) { if (!_.isNumber(clipStartTick)) { throw new Error('clipStartTick must be specified when creating a clip.'); } const clip = new Clip({ // @ts-ignore id: Clip.generateClipIdInternal(), type: ClipType.AUDIO_CLIP, song: this.song, track: undefined, clipStartTick, clipEndTick, audioClipData, }); if (insertClip) { this.insertClip(clip); } return clip; } insertClip(clip: Clip) { if (clip.getTrack() !== this) { if (clip.getTrack()) { // Clip belongs to another track. clip.deleteFromParent(/* deleteAssociatedTrackAutomation= */ false); } // @ts-ignore clip.track = this; } else { // Clip already belongs to the track. return; } // Resolve conflict before inserting a new clip // to preserve the current order of clips. this.resolveClipConflictInternal(clip.getId(), clip.getClipStartTick(), clip.getClipEndTick()); this.orderedInsertClipInternal(clip); } /** * Clones a clip without inserting it into this track, and returns the cloned instance. * * @param clip The clip (not necessarily in this track) to clone. * @returns The cloned clip. */ cloneClip(clip: Clip) { if (clip.getType() === ClipType.MIDI_CLIP) { const newClip = this.createMIDIClip({ clipStartTick: clip.getClipStartTick(), clipEndTick: clip.getClipEndTick(), insertClip: false, }); for (const note of clip.getRawNotes()) { newClip.createNote({ pitch: note.getPitch(), velocity: note.getVelocity(), startTick: note.getStartTick(), endTick: note.getEndTick(), updateClipRange: false, resolveClipConflict: false, }); } return newClip; } else if (clip.getType() === ClipType.AUDIO_CLIP) { const newClip = this.createAudioClip({ clipStartTick: clip.getClipStartTick(), clipEndTick: clip.getClipEndTick(), audioClipData: clip.getAudioClipData() as AudioClipData, insertClip: false, }); return newClip; } else { throw new Error(`Unsupported clip type ${clip.getType()}`); } } /** * Get the index of the clip within the clip list. * * NOTE: This assumes the clip list is sorted. */ getClipIndex(clip: Clip) { const startIndex = lowerEqual( this.clips, clip, (a: Clip, b: Clip) => a.getClipStartTick() - b.getClipStartTick(), ); return this.clips.indexOf(clip, startIndex); } deleteClip(clip: Clip, deleteAssociatedTrackAutomation: boolean) { const index = this.getClipIndex(clip); this.deleteClipAt(index, deleteAssociatedTrackAutomation); } deleteClipAt(index: number, deleteAssociatedTrackAutomation: boolean) { if (index < 0) { return; } if (deleteAssociatedTrackAutomation) { const clip = this.clips[index]; if (!clip) { return; } this.automation.removeAllPointsWithinRange(clip.getClipStartTick(), clip.getClipEndTick()); } this.clips.splice(index, 1); } deleteFromParent() { this.song.removeTrack(this.getId()); } getAutomation() { return this.automation; } /** Sets the automation data of this track as a copy of the given automation data. */ setAutomation(newAutomation: AutomationData) { this.automation = newAutomation.clone(); } /** Whether this track has any defined automation. */ hasAnyAutomation() { return ( this.automation.getAutomationTargets().length > 0 && !_.isEmpty(this.automation.getAutomationTargetValues()) ); } getAuxTrackData() { return this.auxTrackData; } /** * Get the number of specified sends. * * NOTE: Do not use this as the length of the sends array since many entries might be undefined. */ getSendCount() { return _.keys(this.sends).length; } getSendAt(index: number) { return this.sends[index]; } removeSendAt(index: number) { delete this.sends[index]; } setSendAt(index: number, send: TrackSend) { if (index >= Track.MAX_NUM_SENDS) { throw new Error(`Maximum of supported sends is ${Track.MAX_NUM_SENDS}`); } if (this.type === TrackType.MASTER_TRACK) { throw new Error('Cannot add send for master track'); } this.sends[index] = send; } getOutput() { return this.output; } setOutput({ type, trackId = undefined }: { type: TrackOutputType; trackId?: string }) { if (this.type === TrackType.MASTER_TRACK) { throw new Error(`Master track can only output to the default device.`); } if (type !== TrackOutputType.Track) { throw new Error('Non-track output type is not supported yet.'); } if (trackId === this.getId()) { throw new Error('Cannot set output to the track itself.'); } this.output = new TrackOutput({ type, trackId, }); } removeOutput() { delete this.output; } /** Gets all visible notes in this track sorted by start time. */ getVisibleNotes() { const visibleNotes = []; for (const clip of this.getClips()) { for (const note of clip.getNotes()) { visibleNotes.push(note); } } return visibleNotes.sort((a, b) => a.getStartTick() - b.getStartTick()); } private static generateTrackIdInternal() { return nanoid(); } /** * NOTE: Always resolve conflict BEFORE you make any changes to any clips, * so that the order of the clips are still maintained. * * @param clipId * @param startTick * @param endTick */ protected resolveClipConflictInternal(clipId: string, startTick: number, endTick: number) { const overlappingClips = this.getClipsOverlappingWith(startTick, endTick); for (const clip of overlappingClips) { if (clip.getId() === clipId) { continue; } // @ts-ignore clip.trimConflictPartInternal(startTick, endTick); } } private orderedInsertClipInternal(newClip: Clip) { const insertIndex = greaterEqual( this.clips, newClip, (a: Clip, b: Clip) => a.getClipStartTick() - b.getClipStartTick(), ); this.clips.splice(insertIndex, 0, newClip); } } export enum TrackSendPosition { Undefined = 0, PreFader = 1, PostFader = 2, // TODO: Support PostPan } export class TrackSend { private outputBusRank: number; private gainLevel: number; private position: TrackSendPosition; private muted: boolean; constructor({ outputBusRank, gainLevel, position = TrackSendPosition.PostFader, muted = false, }: { /** Rank of the bus to send to. Valid value ranges from 1 to `Song.NUM_BUSES`. */ outputBusRank: number; /** A float value from 0 to 1, indicating the send level fader position. */ gainLevel: number; position: TrackSendPosition; muted?: boolean; }) { this.outputBusRank = outputBusRank; this.gainLevel = TrackSend.checkGainLevel(gainLevel); this.position = position; this.muted = _.isBoolean(muted) ? muted : false; } static checkGainLevel(level: number) { if (level < 0 || level > 1) { throw new Error(`Send gain level ${level} out of valid range 0 - 1.`); } return level; } /** * @returns Rank of the bus to send to. Valid value ranges from 1 to `Song.NUM_BUSES`. */ getOutputBusRank() { return this.outputBusRank; } /** * @param rank Rank of the bus to send to. Valid value ranges from 1 to `Song.NUM_BUSES`. */ setOutputBusRank(rank: number) { this.outputBusRank = rank; } /** * @returns A float value from 0 to 1, indicating the send gain fader position. */ getGainLevel() { return this.gainLevel; } /** * @param level A float value from 0 to 1, indicating the send gain fader position. */ setGainLevel(level: number) { this.gainLevel = TrackSend.checkGainLevel(level); } getPosition() { return this.position; } setPosition(position: TrackSendPosition) { this.position = position; } getMuted() { return this.muted; } setMuted(muted: boolean) { this.muted = muted; } } /** * Information about how an instrument should be played. */ export class InstrumentInfo { private program: number; private isDrum: boolean; constructor({ program, isDrum, }: { /** * General MIDI program number(counting from 0, i.e. "Acoustic Grand Piano" === 0). * * https://www.midi.org/specifications-old/item/gm-level-1-sound-set */ program: number; /** * Whether this instrument is a percussion instrument * (or using channel 9(counting from 0) if you know what it means). */ isDrum: boolean; }) { this.program = program; this.isDrum = isDrum; } getProgram() { return this.program; } getIsDrum() { return this.isDrum; } clone() { return new InstrumentInfo({ program: this.program, isDrum: this.isDrum, }); } } export class AuxTrackData { private inputBusRank?: number; /** * @param rank Rank of the bus to be used as the input, ranges from 1 to Song.NUM_BUSES. */ setInputBusRank(rank: number) { this.inputBusRank = rank; } getInputBusRank() { return this.inputBusRank; } removeInputBus() { delete this.inputBusRank; } } export enum TrackOutputType { Undefined = 0, Device = 1, Track = 2, } export class TrackOutput { private type: TrackOutputType; private trackId?: string; /** * DO NOT call this constructor directly, use `track.setOutput` instead. */ constructor({ type, trackId = undefined }: { type: TrackOutputType; trackId?: string }) { this.type = type; this.trackId = trackId; } getType() { return this.type; } getTrackId() { return this.trackId; } } export enum MelodicInstrumentType { AcousticGrandPiano = 0, BrightAcousticPiano = 1, ElectricGrandPiano = 2, HonkyTonkPiano = 3, ElectricPiano1 = 4, ElectricPiano2 = 5, Harpsichord = 6, Clavinet = 7, Celesta = 8, Glockenspiel = 9, Musicalbox = 10, Vibraphone = 11, Marimba = 12, Xylophone = 13, TubularBell = 14, Dulcimer = 15, DrawbarOrgan = 16, PercussiveOrgan = 17, RockOrgan = 18, Churchorgan = 19, Reedorgan = 20, Accordion = 21, Harmonica = 22, TangoAccordion = 23, AcousticGuitarNylon = 24, AcousticGuitarSteel = 25, ElectricGuitarJazz = 26, ElectricGuitarClean = 27, ElectricGuitarMuted = 28, OverdrivenGuitar = 29, DistortionGuitar = 30, Guitarharmonics = 31, AcousticBass = 32, ElectricBassFinger = 33, ElectricBassPick = 34, FretlessBass = 35, SlapBass1 = 36, SlapBass2 = 37, SynthBass1 = 38, SynthBass2 = 39, Violin = 40, Viola = 41, Cello = 42, Contrabass = 43, TremoloStrings = 44, PizzicatoStrings = 45, OrchestralHarp = 46, Timpani = 47, StringEnsemble1 = 48, StringEnsemble2 = 49, SynthStrings1 = 50, SynthStrings2 = 51, VoiceAahs = 52, VoiceOohs = 53, SynthVoice = 54, OrchestraHit = 55, Trumpet = 56, Trombone = 57, Tuba = 58, MutedTrumpet = 59, Frenchhorn = 60, BrassSection = 61, SynthBrass1 = 62, SynthBrass2 = 63, SopranoSax = 64, AltoSax = 65, TenorSax = 66, BaritoneSax = 67, Oboe = 68, EnglishHorn = 69, Bassoon = 70, Clarinet = 71, Piccolo = 72, Flute = 73, Recorder = 74, PanFlute = 75, BlownBottle = 76, Shakuhachi = 77, Whistle = 78, Ocarina = 79, Lead1Square = 80, Lead2Sawtooth = 81, Lead3Calliope = 82, Lead4Chiff = 83, Lead5Charang = 84, Lead6Voice = 85, Lead7Fifths = 86, Lead8BassLead = 87, Pad1NewAge = 88, Pad2Warm = 89, Pad3PolySynth = 90, Pad4Choir = 91, Pad5Bowed = 92, Pad6Metallic = 93, Pad7Halo = 94, Pad8Sweep = 95, FX1Rain = 96, FX2Soundtrack = 97, FX3Crystal = 98, FX4Atmosphere = 99, FX5Brightness = 100, FX6Goblins = 101, FX7Echoes = 102, FX8SciFi = 103, Sitar = 104, Banjo = 105, Shamisen = 106, Guzheng = 107, Kalimba = 108, Bagpipe = 109, Fiddle = 110, Shanai = 111, TinkleBell = 112, Agogo = 113, SteelDrums = 114, Woodblock = 115, TaikoDrum = 116, MelodicTom = 117, SynthDrum = 118, ReverseCymbal = 119, GuitarFretNoise = 120, BreathNoise = 121, Seashore = 122, BirdTweet = 123, TelephoneRing = 124, Helicopter = 125, Applause = 126, Gunshot = 127, } /** * A drum type to drum pitch mapping. * * The pitch number is used on a drum kit track where program==0 && isDrum==true. */ export enum DrumInstrumentType { BassDrum2 = 35, BassDrum1 = 36, SideStick = 37, SnareDrum1 = 38, HandClap = 39, SnareDrum2 = 40, LowTom2 = 41, ClosedHiHat = 42, LowTom1 = 43, PedalHiHat = 44, MidTom2 = 45, OpenHiHat = 46, MidTom1 = 47, HighTom2 = 48, CrashCymbal1 = 49, HighTom1 = 50, RideCymbal1 = 51, ChineseCymbal = 52, RideBell = 53, Tambourine = 54, SplashCymbal = 55, Cowbell = 56, CrashCymbal2 = 57, VibraSlap = 58, RideCymbal2 = 59, HighBongo = 60, LowBongo = 61, MuteHighConga = 62, OpenHighConga = 63, LowConga = 64, HighTimbale = 65, LowTimbale = 66, HighAgogo = 67, LowAgogo = 68, Cabasa = 69, Maracas = 70, ShortWhistle = 71, LongWhistle = 72, ShortGuiro = 73, LongGuiro = 74, Claves = 75, HighWoodBlock = 76, LowWoodBlock = 77, MuteCuica = 78, OpenCuica = 79, MuteTriangle = 80, OpenTriangle = 81, Shaker = 82, }