tuneflow
Version:
Programmable, extensible music composition & arrangement
1,243 lines (1,158 loc) • 36.1 kB
text/typescript
import { ge as greaterEqual, lt as lowerThan, le as lowerEqual } from 'binary-search-bounds';
import cloneDeep from 'lodash.clonedeep';
import * as _ from 'underscore';
import type { TuneflowPlugin } from '../base_plugin';
import { TempoEvent } from './tempo';
import { TimeSignatureEvent } from './time_signature';
import { Track, TrackOutputType, TrackSend, TrackType } from './track';
import type { AuxTrackData, TrackOutput } from './track';
import { Midi } from '@tonejs/midi';
import { AutomationTarget, AutomationTargetType } from './automation';
import type { AutomationValue } from './automation';
import { AudioPlugin } from './audio_plugin';
import { StructureMarker } from './marker';
import { Lyrics } from './lyric';
import type { StructureType } from './marker';
import { DEFAULT_PPQ } from '../utils';
export class Song {
/** The default PPQ used in TuneFlow. */
static DEFAULT_PPQ = DEFAULT_PPQ;
static NUM_BUSES = 32;
private masterTrack?: Track;
private tracks: Track[];
private PPQ: number;
private tempos: TempoEvent[];
private timeSignatures: TimeSignatureEvent[];
private structures: StructureMarker[];
private pluginContext?: PluginContext;
private nextTrackRank = 1;
private buses: Bus[] = [];
private lyrics: Lyrics;
constructor() {
this.tracks = [];
this.PPQ = 0;
this.tempos = [];
this.timeSignatures = [];
this.structures = [];
this.lyrics = new Lyrics({ song: this });
}
static create() {
const newSong = new Song();
newSong.setResolution(Song.DEFAULT_PPQ);
newSong.createTempoChange({ ticks: 0, bpm: 120 });
newSong.createTimeSignature({ ticks: 0, numerator: 4, denominator: 4 });
return newSong;
}
getBusByRank(rank: number) {
return this.buses[rank - 1];
}
setBus(rank: number, name: string) {
if (rank > Song.NUM_BUSES) {
throw new Error(`Only ${Song.NUM_BUSES} buses are supported.`);
}
const index = rank - 1;
if (!this.buses[index]) {
this.buses[index] = new Bus({ rank, name });
} else {
this.buses[index].setName(name);
}
}
getMasterTrack() {
if (!this.masterTrack) {
this.masterTrack = new Track({
type: TrackType.MASTER_TRACK,
song: this,
// @ts-ignore
uuid: Track.generateTrackIdInternal(),
});
}
return this.masterTrack;
}
/**
* @returns All tracks in previously stored order.
*/
getTracks(): Track[] {
return this.tracks;
}
getTrackById(trackId: string) {
return _.find(this.tracks, track => track.getId() === trackId);
}
getTracksByIds(trackIds: string[]) {
if (!this.tracks) {
return [];
}
const trackIdSet = new Set<string>(trackIds);
return this.tracks.filter(track => trackIdSet.has(track.getId()));
}
/**
* Get the index of the track within the tracks list.
* Returns -1 if no track matches the track id.
*/
getTrackIndex(trackId: string) {
return _.findIndex(this.tracks, track => track.getId() === trackId);
}
/**
* Adds a new track into the song and returns it.
*
* Requires `createTrack` access.
*/
createTrack({
type,
index,
rank,
assignDefaultSamplerPlugin = false,
}: {
type: TrackType;
/** Index to insert at. If left blank, appends to the end. */
index?: number;
/** The displayed rank which uniquely identifies a track. Internal use, do not set this. */
rank?: number;
/** Whether to assign a default sampler plugin if type is `MIDI_TRACK`. */
assignDefaultSamplerPlugin?: boolean;
}): Track {
if (rank == undefined || rank === null) {
rank = this.getNextTrackRank();
} else {
this.nextTrackRank = Math.max(rank + 1, this.nextTrackRank);
}
const track = new Track({
type,
song: this,
uuid: this.getNextTrackId(),
rank: rank == undefined || rank === null ? this.getNextTrackRank() : rank,
});
if (assignDefaultSamplerPlugin && type === TrackType.MIDI_TRACK) {
track.setSamplerPlugin(track.createAudioPlugin(AudioPlugin.DEFAULT_SYNTH_TFID));
}
if (type === TrackType.AUX_TRACK) {
(track.getAuxTrackData() as AuxTrackData).setInputBusRank(1);
}
if (index !== undefined && index !== null) {
this.tracks.splice(index, 0, track);
} else {
this.tracks.push(track);
}
return track;
}
/**
* Clones a track and inserts it in this song and returns the cloned instance.
* @param track The track in this song to clone.
* @returns The cloned track.
*/
cloneTrack(track: Track) {
let trackIndex: any = this.getTrackIndex(track.getId());
if (trackIndex < 0) {
// Track not found in this song, new track will be inserted at the end.
trackIndex = undefined;
}
const newTrack = this.createTrack({
type: track.getType(),
index: trackIndex,
});
newTrack.setVolume(track.getVolume());
newTrack.setPan(track.getPan());
newTrack.setSolo(track.getSolo());
newTrack.setMuted(track.getMuted());
if (track.getType() === TrackType.MIDI_TRACK) {
const trackInstrument = track.getInstrument();
if (trackInstrument) {
newTrack.setInstrument({
program: trackInstrument.getProgram(),
isDrum: trackInstrument.getIsDrum(),
});
}
for (const existingInstrument of track.getSuggestedInstruments()) {
newTrack.createSuggestedInstrument({
program: existingInstrument.getProgram(),
isDrum: existingInstrument.getIsDrum(),
});
}
const existingSamplerPlugin = track.getSamplerPlugin();
if (existingSamplerPlugin) {
newTrack.setSamplerPlugin(existingSamplerPlugin.clone(newTrack));
}
}
for (let i = 0; i < Track.MAX_NUM_EFFECTS_PLUGINS; i += 1) {
const audioPlugin = track.getAudioPluginAt(i);
if (!audioPlugin) {
continue;
}
newTrack.setAudioPluginAt(i, audioPlugin.clone(newTrack));
}
for (const clip of track.getClips()) {
const newClip = newTrack.cloneClip(clip);
newTrack.insertClip(newClip);
}
// Clone aux data
const auxTrackData = track.getAuxTrackData();
if (
auxTrackData &&
_.isNumber(auxTrackData.getInputBusRank()) &&
(auxTrackData.getInputBusRank() as number) > 0
) {
(newTrack.getAuxTrackData() as AuxTrackData).setInputBusRank(
auxTrackData.getInputBusRank() as number,
);
}
// Clone sends.
for (let i = 0; i < Track.MAX_NUM_SENDS; i += 1) {
const send = track.getSendAt(i);
if (!send) {
continue;
}
newTrack.setSendAt(
i,
new TrackSend({
outputBusRank: send.getOutputBusRank(),
gainLevel: send.getGainLevel(),
position: send.getPosition(),
muted: send.getMuted(),
}),
);
}
// Clone automation.
if (track.hasAnyAutomation()) {
newTrack.setAutomation(track.getAutomation());
}
// Clone track output.
if (track.getOutput()) {
const originalOutput = track.getOutput() as TrackOutput;
newTrack.setOutput({
type: originalOutput.getType(),
trackId: originalOutput.getTrackId(),
});
}
return newTrack;
}
/**
* Removes a new track from the song and returns it.
*
* Requires `removeTrack` access.
*/
removeTrack(trackId: string) {
const track = this.getTrackById(trackId);
if (!track) {
return null;
}
this.tracks.splice(
_.findIndex(this.tracks, track => track.getId() === trackId),
1,
);
// Delete dependencies.
for (const depTrack of this.tracks) {
const trackOutput = depTrack.getOutput();
if (
trackOutput &&
trackOutput.getType() === TrackOutputType.Track &&
trackOutput.getTrackId() === trackId
) {
depTrack.removeOutput();
}
}
return track;
}
/**
* @returns The resolution of the song in Pulse-per-quarter.
*/
getResolution(): number {
return this.PPQ;
}
/**
* Sets resolution in Pulse-per-quarter.
* @param resolution
*/
setResolution(resolution: number) {
this.PPQ = resolution;
}
static getLeadingBar(tick: number, barBeats: BarBeat[]) {
if (!barBeats || barBeats.length === 0) {
return null;
}
if (tick < 0) {
return barBeats[0];
}
let index = lowerEqual(barBeats, { tick } as any, (a, b) => a.tick - b.tick);
while (index > 0 && barBeats[index].beat !== 1) {
index -= 1;
}
return barBeats[index];
}
static getLeadingBeat(tick: number, barBeats: BarBeat[]) {
if (!barBeats || barBeats.length === 0) {
return null;
}
if (tick < 0) {
return barBeats[0];
}
const index = lowerEqual(barBeats, { tick } as any, (a, b) => a.tick - b.tick);
return barBeats[index];
}
static getTrailingBeat(tick: number, barBeats: BarBeat[]) {
if (!barBeats || barBeats.length === 0) {
return null;
}
if (tick < 0) {
return barBeats[0];
}
const index = greaterEqual(barBeats, { tick } as any, (a, b) => a.tick - b.tick);
if (index > barBeats.length - 1) {
return barBeats[barBeats.length - 1];
}
return barBeats[index];
}
static getClosestBeat(tick: number, barBeats: BarBeat[]) {
if (!barBeats || barBeats.length === 0) {
return null;
}
if (tick < 0) {
return barBeats[0];
}
const index = lowerEqual(barBeats, { tick } as any, (a, b) => a.tick - b.tick);
if (index >= barBeats.length - 1) {
return barBeats[index];
}
if (Math.abs(barBeats[index].tick - tick) > Math.abs(barBeats[index + 1].tick - tick)) {
// tick is closer to the next beat.
return barBeats[index + 1];
}
return barBeats[index];
}
getBarBeats(endTick: number) {
return Song.getBarBeatsImpl<TimeSignatureEvent>(
endTick,
this.PPQ,
this.timeSignatures,
signature => ({
tick: signature.getTicks(),
numerator: signature.getNumerator(),
denominator: signature.getDenominator(),
}),
);
}
/** Gets a list of all bar beats and the corresponding ticks. */
static getBarBeatsImpl<T>(
endTick: number,
ppq: number,
timeSignatures: T[],
parseTimeSignatureFn: (signature: T) => any,
): BarBeat[] {
if (timeSignatures.length === 0) {
return [];
}
// Dedupe time signatures.
const dedupedTimeSignatures = [];
for (let i = 0; i < timeSignatures.length; i += 1) {
if (dedupedTimeSignatures.length === 0) {
dedupedTimeSignatures.push(timeSignatures[i]);
} else {
const currentTimeSignatureInfo = parseTimeSignatureFn(timeSignatures[i]);
const prevTimeSignatureInfo = parseTimeSignatureFn(
dedupedTimeSignatures[dedupedTimeSignatures.length - 1],
);
if (
currentTimeSignatureInfo.numerator !== prevTimeSignatureInfo.numerator ||
currentTimeSignatureInfo.denominator !== prevTimeSignatureInfo.denominator
) {
dedupedTimeSignatures.push(timeSignatures[i]);
}
}
}
// Calculate bar beats.
const barBeats: BarBeat[] = [];
let currentTick = 0;
let currentTimeSignatureIndex = 0;
let bar = 1;
let beat = 1;
while (currentTick <= endTick) {
if (currentTimeSignatureIndex < dedupedTimeSignatures.length - 1) {
const nextTimeSignatureInfo = parseTimeSignatureFn(
dedupedTimeSignatures[currentTimeSignatureIndex + 1],
);
const nextSwitchingTick = nextTimeSignatureInfo.tick;
if (currentTick >= nextSwitchingTick) {
currentTick = nextSwitchingTick;
currentTimeSignatureIndex += 1;
if (beat > 1) {
// The bar before the time signature change did not finish,
// move on to the next bar.
beat = 1;
bar += 1;
}
}
}
const currentTimeSignatureInfo = parseTimeSignatureFn(
dedupedTimeSignatures[currentTimeSignatureIndex],
);
barBeats.push({
bar,
beat,
tick: currentTick,
numerator: beat === 1 ? currentTimeSignatureInfo.numerator : undefined,
denominator: beat === 1 ? currentTimeSignatureInfo.denominator : undefined,
ticksPerBeat: beat === 1 ? (ppq * 4) / currentTimeSignatureInfo.denominator : undefined,
});
if (beat >= currentTimeSignatureInfo.numerator) {
beat = 1;
bar += 1;
} else {
beat += 1;
}
currentTick += (ppq * 4) / currentTimeSignatureInfo.denominator;
}
return barBeats;
}
/**
* @returns A list of tempo change events ordered by occurrence time.
*/
getTempoChanges(): TempoEvent[] {
return this.tempos;
}
getTempoAtTick(tick: number) {
return Song.getTempoAtTickImpl<TempoEvent>(
tick,
this.tempos,
tick =>
({
getTicks: () => tick,
} as any),
tempo => tempo.getTicks(),
);
}
static getTempoAtTickImpl<T>(
tick: number,
tempos: T[],
tickToTempoFn: (tick: number) => T,
tempoToTickFn: (tempo: T) => number,
): T {
let index = lowerEqual(
tempos,
tickToTempoFn(tick),
(a, b) => tempoToTickFn(a) - tempoToTickFn(b),
);
if (index < 0) {
index = 0;
}
if (index >= tempos.length) {
index = tempos.length - 1;
}
return tempos[index];
}
/**
* Adds a tempo change event into the song and returns it.
* @returns
*/
createTempoChange({
ticks,
bpm,
}: {
/** The tick at which this event happens. */
ticks: number;
/** The new tempo in BPM(Beats-per-minute) format. */
bpm: number;
}): TempoEvent {
if (this.PPQ <= 0) {
throw new Error('Song resolution must be provided before creating tempo changes.');
}
if (this.tempos.length === 0 && ticks !== 0) {
throw new Error('The first tempo event must be at tick 0');
}
// Calculate time BEFORE the new tempo event is inserted.
const tempoChange = new TempoEvent({ ticks, bpm, time: this.tickToSeconds(ticks) });
const insertIndex = greaterEqual(
this.tempos,
tempoChange,
(a: TempoEvent, b: TempoEvent) => a.getTicks() - b.getTicks(),
);
if (insertIndex < 0) {
this.tempos.push(tempoChange);
} else {
this.tempos.splice(insertIndex, 0, tempoChange);
}
this.retimingTempoEvents();
return tempoChange;
}
removeTempoChange(index: number) {
if (this.getTempoChanges().length <= 1) {
throw new Error('Song has to have at least one tempo change.');
}
if (index === 0) {
throw new Error('Cannot remove the first tempo.');
}
this.getTempoChanges().splice(index, /* deleteCount= */ 1);
this.retimingTempoEvents();
}
/**
* Overwrite the existing tempo changes with the new tempo changes. Tempo times will be re-calculated.
*
* You can omit time in the new `TempoEvent`s since we'll re-calculate them altogether.
*
* Note that the new tempo events must have one tempo event starting at tick 0.
* @param tempoEvents
*/
overwriteTempoChanges(tempoEvents: TempoEvent[]) {
if (tempoEvents.length === 0) {
throw new Error('Cannot clear all the tempo events.');
}
const sortedTempoEvents = cloneDeep(tempoEvents);
sortedTempoEvents.sort((a: any, b: any) => a.getTicks() - b.getTicks());
const firstTempoEvent = sortedTempoEvents[0];
if (firstTempoEvent.getTicks() > 0) {
console.warn('The first tempo event needs to start from tick 0');
// @ts-ignore
firstTempoEvent.ticks = 0;
firstTempoEvent.setTimeInternal(0);
}
this.tempos = [
new TempoEvent({
ticks: 0,
time: 0,
bpm: firstTempoEvent.getBpm(),
}),
];
for (let i = 1; i < sortedTempoEvents.length; i += 1) {
const tempoEvent = sortedTempoEvents[i];
this.createTempoChange({
ticks: tempoEvent.getTicks(),
bpm: tempoEvent.getBpm(),
});
}
this.retimingTempoEvents();
}
updateTempo(tempoEvent: TempoEvent, newBPM: number) {
tempoEvent.setBpmInternal(newBPM);
this.retimingTempoEvents();
}
moveTempo(tempoIndex: number, moveToTick: number) {
const tempo = this.getTempoChanges()[tempoIndex];
if (!tempo) {
return;
}
if (tempoIndex === 0) {
// Cannot move the first tempo.
return;
}
const prevTempo = this.getTempoChanges()[tempoIndex - 1];
if (prevTempo.getTicks() === moveToTick) {
// Moved to another tempo, delete it.
this.removeTempoChange(tempoIndex - 1);
} else if (tempoIndex < this.getTempoChanges().length - 1) {
const nextTempo = this.getTempoChanges()[tempoIndex + 1];
if (nextTempo && nextTempo.getTicks() === moveToTick) {
// Moved to another tempo, delete it.
this.removeTempoChange(tempoIndex + 1);
}
}
// @ts-ignore
tempo.ticks = moveToTick;
this.retimingTempoEvents();
}
/**
* Create a new tempo at the tick or update the existing
* tempo if any.
* @param tick
* @param newBPM
*/
updateTempoAtTick(tick: number, newBPM: number) {
const existingEvent = this.getTempoAtTick(tick);
if (existingEvent) {
this.updateTempo(existingEvent, newBPM);
} else {
this.createTempoChange({
ticks: tick,
bpm: newBPM,
});
}
}
getTimeSignatures(): TimeSignatureEvent[] {
return this.timeSignatures;
}
/**
* Overwrite all existing time signatures with the given new time signatures.
* @param timeSignatures
*/
overwriteTimeSignatures(timeSignatures: TimeSignatureEvent[]) {
if (timeSignatures.length === 0) {
throw new Error('At least one time signature needs to be present.');
}
this.timeSignatures = cloneDeep(timeSignatures);
}
createTimeSignature({
ticks,
numerator,
denominator,
}: {
/** The tick at which this event happens. */
ticks: number;
numerator: number;
denominator: number;
}): TimeSignatureEvent {
const timeSignature = new TimeSignatureEvent({ ticks, numerator, denominator });
const insertIndex = greaterEqual(
this.timeSignatures,
timeSignature,
(a: TimeSignatureEvent, b: TimeSignatureEvent) => a.getTicks() - b.getTicks(),
);
if (insertIndex < 0) {
this.timeSignatures.push(timeSignature);
} else {
this.timeSignatures.splice(insertIndex, 0, timeSignature);
}
return timeSignature;
}
removeTimeSignature(index: number) {
if (this.getTimeSignatures().length <= 1) {
throw new Error('Song has to have at least one time signature change.');
}
if (index === 0) {
throw new Error('Cannot remove the first time signature.');
}
this.getTimeSignatures().splice(index, /* deleteCount= */ 1);
}
/**
* Create a new time signature at the tick or update the existing
* signature if any.
* @param tick
*/
updateTimeSignatureAtTick(tick: number, numerator: number, denominator: number) {
const existingEvent = this.getTimeSignatureAtTick(tick);
if (existingEvent) {
existingEvent.setNumerator(numerator);
existingEvent.setDenominator(denominator);
} else {
this.createTimeSignature({
ticks: tick,
numerator,
denominator,
});
}
}
moveTimeSignature(timeSignatureIndex: number, moveToTick: number) {
const timeSignature = this.getTimeSignatures()[timeSignatureIndex];
if (!timeSignature) {
return;
}
if (timeSignatureIndex == 0) {
// Cannot move the first time signature.
return;
}
const prevTimeSignature = this.getTimeSignatures()[timeSignatureIndex - 1];
if (prevTimeSignature.getTicks() === moveToTick) {
// Moved to another time signature, delete it.
this.removeTimeSignature(timeSignatureIndex - 1);
} else if (timeSignatureIndex < this.getTimeSignatures().length - 1) {
const nextTimeSignature = this.getTimeSignatures()[timeSignatureIndex + 1];
if (nextTimeSignature && nextTimeSignature.getTicks() === moveToTick) {
// Moved to another time signature, delete it.
this.removeTimeSignature(timeSignatureIndex + 1);
}
}
// @ts-ignore
timeSignature.ticks = moveToTick;
this.timeSignatures.sort((a, b) => a.getTicks() - b.getTicks());
}
getStructures() {
return this.structures;
}
getStructureAtIndex(index: number) {
return this.structures[index];
}
getStructureAtTick(tick: number) {
return Song.getStructureAtTickImpl<StructureMarker>(
tick,
this.structures,
tick => ({ getTick: () => tick } as any),
structure => structure.getTick(),
);
}
static getStructureAtTickImpl<T>(
tick: number,
structures: T[],
tickToStructureFn: (tick: number) => T,
structureToTickFn: (structure: T) => number,
): T {
let index = lowerEqual(
structures,
tickToStructureFn(tick),
(a, b) => structureToTickFn(a) - structureToTickFn(b),
);
if (index < 0) {
index = 0;
}
if (index >= structures.length) {
index = structures.length - 1;
}
return structures[index];
}
createStructure({
tick,
type,
customName,
}: {
tick: number;
type: StructureType;
customName?: string;
}) {
const structure = new StructureMarker({
tick,
type,
customName,
});
this.structures.push(structure);
if (this.structures.length === 1) {
// If there are only 1 structure, move it to the start.
structure.setTick(0);
}
this.structures.sort((a, b) => a.getTick() - b.getTick());
}
moveStructure(structureIndex: number, moveToTick: number) {
if (structureIndex <= 0) {
return;
}
const structure = this.getStructures()[structureIndex];
if (!structure) {
return;
}
const prevStructure = this.getStructures()[structureIndex - 1];
if (prevStructure.getTick() === moveToTick) {
// Moved to another structure, delete it.
this.removeStructure(structureIndex - 1);
} else if (structureIndex < this.getStructures().length - 1) {
const nextStructure = this.getStructures()[structureIndex + 1];
if (nextStructure && nextStructure.getTick() === moveToTick) {
// Moved to another time signature, delete it.
this.removeStructure(structureIndex + 1);
}
}
// @ts-ignore
structure.tick = moveToTick;
this.structures.sort((a, b) => a.getTick() - b.getTick());
}
updateStructureAtTick(tick: number, type: StructureType) {
const existinStructure = this.getStructureAtTick(tick);
if (existinStructure) {
existinStructure.setType(type);
} else {
this.createStructure({
tick,
type,
});
}
}
removeStructure(index: number) {
if (index < 0 || index >= this.structures.length) {
return;
}
this.getStructures().splice(index, /* deleteCount= */ 1);
if (this.structures.length > 0 && this.structures[0].getTick() > 0) {
// If the first structure of the remaining ones does not start
// from 0, move it to 0.
this.structures[0].setTick(0);
}
this.structures.sort((a, b) => a.getTick() - b.getTick());
}
/**
*
* @returns End tick of the last note.
*/
getLastTick() {
let lastTick = 0;
for (const track of this.tracks) {
lastTick = Math.max(lastTick, track.getTrackEndTick());
}
return lastTick;
}
/**
* @returns Total duration of the song in seconds.
*/
getDuration() {
return this.tickToSeconds(this.getLastTick());
}
/**
* This is the number of ticks per beat based on the time signature.
*
* This should be distinguished from `getResolution()`, which is
* the number of ticks per quater note.
*/
getTicksPerBeatAtTick(tick: number) {
const timeSignature = this.getTimeSignatureAtTick(tick);
return this.getResolution() * (4 / timeSignature.getDenominator());
}
getTimeSignatureAtTick(tick: number) {
return Song.getTimeSignatureAtTickImpl<TimeSignatureEvent>(
tick,
this.timeSignatures,
tick =>
({
getTicks: () => tick,
} as any),
timeSignature => timeSignature.getTicks(),
);
}
static getTimeSignatureAtTickImpl<T>(
tick: number,
timeSignatures: T[],
tickToTimeSignatureFn: (tick: number) => T,
timeSignatureToTickFn: (timeSignature: T) => number,
): T {
let index = lowerEqual(
timeSignatures,
tickToTimeSignatureFn(tick),
(a, b) => timeSignatureToTickFn(a) - timeSignatureToTickFn(b),
);
if (index < 0) {
index = 0;
}
if (index >= timeSignatures.length) {
index = timeSignatures.length - 1;
}
return timeSignatures[index];
}
tickToSeconds(tick: number) {
return Song.tickToSecondsImpl<TempoEvent>(
tick,
this.getTempoChanges(),
this.getResolution(),
tick =>
({
getTicks: () => tick,
} as any),
tempo => tempo.getTicks(),
tempo => ({
tick: tempo.getTicks(),
bpm: tempo.getBpm(),
time: tempo.getTime(),
}),
);
}
static tickToSecondsImpl<T>(
tick: number,
tempos: T[],
resolution: number,
tickToTempoFn: (tick: number) => T,
tempoToTickFn: (tempo: T) => number,
parseTempoFn: (tempo: T) => any,
): number {
if (tick === 0) {
return 0;
}
let baseTempoIndex = lowerThan(
tempos,
tickToTempoFn(tick),
(a, b) => tempoToTickFn(a) - tempoToTickFn(b),
);
if (baseTempoIndex == -1) {
// If no tempo is found before the tick, use the first tempo.
baseTempoIndex = 0;
}
const baseTempoChange = tempos[baseTempoIndex];
const baseTempoInfo = parseTempoFn(baseTempoChange);
const ticksDelta = tick - baseTempoInfo.tick;
const ticksPerSecondSinceLastTempoChange = Song.tempoBPMToTicksPerSecond(
baseTempoInfo.bpm as number,
resolution,
);
return baseTempoInfo.time + ticksDelta / ticksPerSecondSinceLastTempoChange;
}
secondsToTick(seconds: number) {
return Song.secondsToTickImpl<TempoEvent>(
seconds,
this.PPQ,
this.tempos,
seconds => ({ getTime: () => seconds } as any),
tempo => tempo.getTime(),
tempo => ({
tick: tempo.getTicks(),
bpm: tempo.getBpm(),
time: tempo.getTime(),
}),
);
}
advanceTickByBars(
originalTick: number,
offsetBar: number,
offsetBeat: number,
barBeats: BarBeat[],
) {
if (originalTick < 0) {
// Cannot offset negative tick
return null;
}
if (offsetBar < 0 || offsetBeat < 0) {
// Bar and beat offsets have to be positive;
return null;
}
let barBeatIndex = lowerEqual(
barBeats,
{ tick: originalTick } as any,
(a, b) => a.tick - b.tick,
);
const startingBarBeat = barBeats[barBeatIndex < 0 ? 0 : barBeatIndex];
if (!startingBarBeat) {
return null;
}
for (; barBeatIndex < barBeats.length; barBeatIndex += 1) {
const currentBarBeat = barBeats[barBeatIndex];
if (currentBarBeat.bar - startingBarBeat.bar >= offsetBar) {
if (
currentBarBeat.bar - startingBarBeat.bar > offsetBar ||
currentBarBeat.beat >= offsetBeat + 1
) {
break;
}
}
}
const endingBarBeat = barBeats[barBeatIndex];
return endingBarBeat ? endingBarBeat.tick : null;
}
static secondsToTickImpl<T>(
seconds: number,
resolution: number,
tempos: T[],
secondsToTempoFn: (seconds: number) => T,
tempoToSecondsFn: (tempo: T) => number,
parseTempoFn: (tempo: T) => any,
) {
if (seconds === 0) {
return 0;
}
let baseTempoIndex = lowerThan(
tempos,
secondsToTempoFn(seconds),
(a, b) => tempoToSecondsFn(a) - tempoToSecondsFn(b),
);
if (baseTempoIndex == -1) {
// If no tempo is found before the time, use the first tempo.
baseTempoIndex = 0;
}
const baseTempoChange = tempos[baseTempoIndex];
const baseTempoInfo = parseTempoFn(baseTempoChange);
const timeDelta = seconds - baseTempoInfo.time;
const ticksPerSecondSinceLastTempoChange = Song.tempoBPMToTicksPerSecond(
baseTempoInfo.bpm,
resolution,
);
return Math.round(baseTempoInfo.tick + timeDelta * ticksPerSecondSinceLastTempoChange);
}
/**
* Import a midi file into the song, returns the updated or newly created tracks.
*
* @param insertAtIndex If >= 0, the new midi clips will be inserted to tracks starting from the given index, one clip per track.
*/
static importMIDI(
song: Song,
fileBuffer: ArrayBuffer,
insertAtTick = 0,
overwriteTemposAndTimeSignatures = false,
insertAtIndex = -1,
) {
const midi = new Midi(fileBuffer);
const insertOffset = insertAtTick;
// For songs that are not 480 PPQ, we need to convert the ticks
// so that the beats and time remain unchanged.
const ppqScaleFactor = Song.DEFAULT_PPQ / midi.header.ppq;
// Optionally overwrite tempos and time signatures.
if (overwriteTemposAndTimeSignatures) {
// Time signatures need to be imported before tempos.
const newTimeSignatureEvents = [];
for (const rawTimeSignatureEvent of midi.header.timeSignatures) {
newTimeSignatureEvents.push(
new TimeSignatureEvent({
ticks: insertOffset + scaleIntBy(rawTimeSignatureEvent.ticks, ppqScaleFactor),
numerator: rawTimeSignatureEvent.timeSignature[0],
denominator: rawTimeSignatureEvent.timeSignature[1],
}),
);
}
if (newTimeSignatureEvents.length > 0) {
if (insertOffset > 0) {
// Move the first time signature to the beginning.
newTimeSignatureEvents[0].setTicks(0);
}
song.overwriteTimeSignatures(newTimeSignatureEvents);
}
const newTempoEvents = [];
for (const rawTempoEvent of midi.header.tempos) {
newTempoEvents.push(
new TempoEvent({
ticks: insertOffset + scaleIntBy(rawTempoEvent.ticks, ppqScaleFactor),
time: rawTempoEvent.time as number,
bpm: rawTempoEvent.bpm,
}),
);
}
if (newTempoEvents.length > 0) {
if (insertOffset > 0) {
// @ts-ignore
newTempoEvents[0].ticks = 0;
}
song.overwriteTempoChanges(newTempoEvents);
}
}
// Add tracks and notes.
if (insertAtIndex < 0) {
insertAtIndex = song.getTracks().length;
}
const createdOrUpdatedTracks = [];
for (const track of midi.tracks) {
let songTrack: Track;
if (insertAtIndex < song.getTracks().length) {
songTrack = song.getTracks()[insertAtIndex];
} else {
songTrack = song.createTrack({
type: TrackType.MIDI_TRACK,
assignDefaultSamplerPlugin: true,
});
}
createdOrUpdatedTracks.push(songTrack);
insertAtIndex += 1;
songTrack.setInstrument({
program: track.instrument.number,
isDrum: track.instrument.percussion,
});
const trackClip = songTrack.createMIDIClip({ clipStartTick: insertOffset });
let minStartTick = Number.MAX_SAFE_INTEGER;
// Add notes.
for (const note of track.notes) {
trackClip.createNote({
pitch: note.midi,
velocity: Math.round(note.velocity * 127),
startTick: insertOffset + scaleIntBy(note.ticks, ppqScaleFactor),
endTick: insertOffset + scaleIntBy(note.ticks + note.durationTicks, ppqScaleFactor),
});
minStartTick = Math.min(
minStartTick,
insertOffset + scaleIntBy(note.ticks, ppqScaleFactor),
);
}
// Add volume automation.
const volumeCCs = track.controlChanges[7];
if (volumeCCs) {
if (volumeCCs.length === 1) {
songTrack.setVolume(volumeCCs[0].value);
} else {
const volumeTarget = new AutomationTarget(AutomationTargetType.VOLUME);
songTrack.getAutomation().addAutomation(volumeTarget);
const volumeTargetValue = songTrack
.getAutomation()
.getAutomationValueByTarget(volumeTarget) as AutomationValue;
for (const cc of volumeCCs) {
volumeTargetValue.addPoint(
insertOffset + scaleIntBy(cc.ticks, ppqScaleFactor),
cc.value,
);
}
}
}
// Add pan automation.
const panCCs = track.controlChanges[10];
if (panCCs) {
if (panCCs.length === 1) {
const actualPan = Math.round(panCCs[0].value * 127 - 64);
songTrack.setPan(actualPan);
} else {
const panTarget = new AutomationTarget(AutomationTargetType.PAN);
songTrack.getAutomation().addAutomation(panTarget);
const panTargetValue = songTrack
.getAutomation()
.getAutomationValueByTarget(panTarget) as AutomationValue;
for (const cc of panCCs) {
panTargetValue.addPoint(insertOffset + scaleIntBy(cc.ticks, ppqScaleFactor), cc.value);
}
}
}
if (minStartTick !== Number.MAX_SAFE_INTEGER) {
trackClip.adjustClipLeft(minStartTick);
}
}
return createdOrUpdatedTracks;
}
protected setPluginContextInternal(plugin: TuneflowPlugin) {
this.pluginContext = {
plugin,
numTracksCreatedByPlugin: 0,
};
}
protected clearPluginContextInternal() {
this.pluginContext = undefined;
}
private getNextTrackId() {
const pluginContext = this.pluginContext as PluginContext;
// @ts-ignore
const pluginGeneratedTrackIds = pluginContext.plugin.generatedTrackIdsInternal;
if (pluginContext.numTracksCreatedByPlugin === pluginGeneratedTrackIds.length) {
// @ts-ignore
pluginGeneratedTrackIds.push(Track.generateTrackIdInternal());
} else if (pluginContext.numTracksCreatedByPlugin > pluginGeneratedTrackIds.length) {
throw new Error('Plugin generated track ids out of sync.');
}
const nextTrackId = pluginGeneratedTrackIds[pluginContext.numTracksCreatedByPlugin];
pluginContext.numTracksCreatedByPlugin += 1;
// @ts-ignore
return nextTrackId;
}
private getNextTrackRank() {
const nextRank = this.nextTrackRank;
this.nextTrackRank += 1;
return nextRank;
}
private static tempoBPMToTicksPerSecond(tempoBPM: number, PPQ: number) {
return (tempoBPM * PPQ) / 60;
}
/**
* Recalculate all tempo event time.
*/
private retimingTempoEvents() {
this.tempos.sort((a, b) => a.getTicks() - b.getTicks());
// Re-calculate all tempo event time.
for (const tempoEvent of this.tempos) {
tempoEvent.setTimeInternal(this.tickToSeconds(tempoEvent.getTicks()));
}
}
getLyrics() {
return this.lyrics;
}
}
interface PluginContext {
plugin: TuneflowPlugin;
numTracksCreatedByPlugin: number;
}
export interface BarBeat {
bar: number;
beat: number;
tick: number;
numerator?: number;
denominator?: number;
ticksPerBeat?: number;
}
function scaleIntBy(val: number, factor: number) {
return Math.round(val * factor);
}
export class Bus {
private rank: number;
private name?: string;
constructor({
rank,
name,
}: {
/**
* Rank of the bus, ranges from 1 to `Song.NUM_BUSES`.
*/
rank: number;
name?: string;
}) {
this.rank = rank;
this.name = name;
}
/**
* @returns Rank of the bus, ranges from 1 to `Song.NUM_BUSES`.
*/
getRank() {
return this.rank;
}
getName() {
return this.name;
}
setName(newName: string) {
this.name = newName;
}
}