UNPKG

musicvis-lib

Version:

Music analysis and visualization library

601 lines (569 loc) 18.1 kB
import { min } from 'd3' import Note from './Note.js' import GuitarNote from './GuitarNote.js' import { clipValue } from '../utils/MathUtils.js' /** * This class represents an array of note objects. * It can be used to simplify operations on a track. * * @example * const notes = [ * // Some Note objects * ]; * const noteArr = new NoteArray(notes) * // Add more notes (all notes will be sorted by time by default after this) * .addNotes([]) * // Scale all note's sart and end time to make a track slower or faster * .scaleTime(0.5) * // Do more ... * // This class also mirrors many functions from the Array class * .sort(sortFunction).filter(filterFunction).map(mapFunction).slice(0, 20) * * // Get Note objects back in a simple Array * const transformedNotes = noteArr.getNotes(); * // [Note, Note, Note, ...] * * // Or use an iterator * for (const note of noteArr) { * console.log(note); * } */ class NoteArray { /** * Creates a new NoteArray, * will make a copy of the passed array and cast all notes * * @param {Note[]} [notes=[]] notes * @param {boolean} [reUseNotes=false] if true, will directly use the passed notes. * This can be dangerous if you do not want them to change. */ constructor (notes = [], reUseNotes = false) { if (reUseNotes) { this._notes = notes } else { // Parse notes this._notes = notes.map(d => { if (d.string !== undefined && d.fret !== undefined) { return GuitarNote.from(d) } return Note.from(d) }) } } /** * Returns a simple array with all Note objects. * * @returns {Note[]} array with Note objects * @example <caption>Getting notes as simple Note[]</caption> * const na = new NoteArray(someNotes); * const notes = na.getNotes(); * @example <caption>Using an iterator instead</caption> * const na = new NoteArray(someNotes); * for (const note of na) { * console.log(note); * } * // Or copy all Notes to an array with * const array = [...na]; */ getNotes () { return this._notes } /** * Overwrite the NoteArray's notes with another Array of Notes * * @param {Note[]} notes notes * @returns {NoteArray} itself */ setNotes (notes) { this._notes = notes return this } /** * Makes this class iterable * * @yields {Note} note * @example <caption>Using an iterator for NoteArray</caption> * const na = new NoteArray(someNotes); * for (const note of na) { * console.log(note); * } */ * [Symbol.iterator] () { for (const note of this._notes) { yield note } } /** * Appends notes to this NoteArray * * @param {Note[]} notes notes * @param {boolean} sort iff ture, sorts notes by start timeafter adding * the new ones (default:true) * @returns {NoteArray} itself */ addNotes (notes, sort = true) { this._notes = [...this._notes, ...notes] if (sort) { this.sortByTime() } return this } /** * Adds the notes from another NoteArray to this NoteArray * IMPORTANT: this does not change the notes or sort them! * Take a look at NoteArray.append() if you want to extend * a track at its end. * * @param {NoteArray} noteArray another NoteArray * @returns {NoteArray} itself */ concat (noteArray) { this._notes = [...this._notes, ...noteArray._notes] return this } /** * Appends notes to the end of this NoteArray, after shifting them by its * duration. Set gap to something != 0 to create a gap or overlap. * * @param {NoteArray} noteArray another NoteArray * @param {number} gap in seconds between the two parts * @returns {NoteArray} itself */ append (noteArray, gap = 0) { const duration = this.getDuration() const clone = noteArray.clone() clone.shiftTime(duration + gap) this._notes = [...this._notes, ...clone._notes] this.sortByTime() return this } /** * Repeats the notes of this array by concatenating a time-shifted copy * * @param {number} times number of times to repeat it * @returns {NoteArray} a new NoteArray with the repeated note sequence */ repeat (times) { const result = this.clone() if (times < 1) { return new NoteArray() } if (times === 1) { return result } const copy = this.clone() const duration = this.getDuration() for (let index = 1; index < times; index++) { // Shift notes in time copy.shiftTime(duration) // Result is a NoteArray so use .concat result.concat(copy) } return result } /** * Returns the number of Note objects in this NoteArray * * @returns {number} note count */ length () { return this._notes.length } /** * Returns the start time of the earliest note in this NoteArray * * @returns {number} start time */ getStartTime () { return min(this._notes, d => d.start) } /** * Returns the duration of this note array in seconds from 0 to the end of * the latest note. * * @returns {number} duration */ getDuration () { let duration = 0 for (const note of this._notes) { const noteEnd = note.end === null ? note.start : note.end if (noteEnd > duration) { duration = noteEnd } } return duration } /** * Scales the time of each note by factor * * @param {number} factor factor * @returns {NoteArray} itself */ scaleTime (factor) { this._notes = this._notes.map(n => n.scaleTime(factor)) return this } /** * Adds the speicifed number of seconds to each note * * @param {number} addedSeconds time to add in seconds * @returns {NoteArray} itself */ shiftTime (addedSeconds) { this._notes = this._notes.map(n => n.shiftTime(addedSeconds)) return this } /** * Moves all notes s.t. the first starts at <start> * Will sort the notes by start time. * * @param {number} startTime the new start time for the earliest note * @returns {NoteArray} itself */ shiftToStartAt (startTime) { this.sortByTime() const firstNoteStart = this._notes[0].start const offset = firstNoteStart - startTime this._notes.forEach(n => { n.start -= offset if (n.end !== null) { n.end -= offset } }) return this } /** * Similar to Array.forEach * * @param {Function} func a function * @returns {NoteArray} this */ forEach (func) { this._notes.forEach( (element, index, array) => func(element, index, array) ) return this } /** * Sorts the notes * * @param {Function} sortFunction sort function, e.g. (a, b)=>a.start-b.start * @returns {NoteArray} itself */ sort (sortFunction) { this._notes = this._notes.sort(sortFunction) return this } /** * Sorts the notes by start time * * @returns {NoteArray} itself */ sortByTime () { this._notes = this._notes.sort((a, b) => a.start - b.start) return this } /** * Maps the notes using some mapping function * * @param {Function} mapFunction mapping function with same signature as * Array.map() * @returns {NoteArray} itself */ map (mapFunction) { this._notes = this._notes.map( (element, index, array) => mapFunction(element, index, array) ) return this } /** * Slices the notes by index, like Array.slice() * * @param {number} start start index * @param {number} end end index * @returns {NoteArray} itself */ slice (start, end) { this._notes = this._notes.slice(start, end) return this } /** * Slices the notes by time. * The modes 'end' and 'contained' will remove all notes with end === null! * Notes will not be changed, e.g. start time will remain the same. * * @param {number} startTime start of the filter range in seconds * @param {number} endTime end of the filter range in seconds (exclusive) * @param {string} [mode=contained] controls which note time to consider, * one of: * - start: note.start must be inside range * - end: note.end must be inside range * - contained: BOTH note.start and note.end must be inside range * - touched: EITHER start or end (or both) must be inside range) * - touched-included: like touched, but also includes notes where * neither start nor end inside range, but range is completely * inside the note * (contained is default) * @returns {NoteArray} itself * @throws {'Invalid slicing mode'} When slicing mode is not one of the * above values */ sliceTime (startTime, endTime, mode = 'contained') { const start = startTime const end = endTime let filterFunc if (mode === 'start') { filterFunc = n => n.start >= start && n.start < end } else if (mode === 'end') { filterFunc = n => n.end !== null && n.end >= start && n.end < end } else if (mode === 'contained') { filterFunc = n => n.end !== null && n.start >= start && n.end < end } else if (mode === 'touched') { filterFunc = n => (n.start >= start && n.start <= end) || (n.end !== null && n.end >= start && n.end <= end) } else if (mode === 'touched-included') { filterFunc = n => // like touched (n.start >= start && n.start <= end) || (n.end !== null && n.end >= start && n.end <= end) || // filter range inside note range (n.end !== null && n.start <= start && n.end >= end) } else { throw new Error('Invalid slicing mode') } this._notes = this._notes.filter(filterFunc) return this } /** * Slices this NoteArray into slices by the given times. Will not return * NoteArrays but simple Note[][], where each item contains all notes of one * time slice. Do not include 0, it will be assumed as first time to slice. * To make sure notes are not contained twice in different slices use the * mode 'start'. * * @param {number[]} times points of time at which to slice (in seconds) * @param {string} mode see NoteArray.sliceTime() * @returns {Note[][]} time slices * @param {boolean} [reUseNotes=false] if true, will not clone notes. * This can be dangerous if you do not want them to change. * @example * // Slice into 1 second slices * const slices = noteArray.sliceAtTimes([1, 2, 3], 'start) */ sliceAtTimes (times, mode, reUseNotes = false) { if (times.length === 0) { return [this._notes] } // Make sure notes at the end are also in a slice const duration = this.getDuration() if (Math.max(...times) <= duration) { times.push(duration + 1) } const slices = [] let lastTime = 0 for (const time of times) { slices.push( // this.clone() new NoteArray(this._notes, reUseNotes) .sliceTime(lastTime, time, mode) .getNotes() ) lastTime = time } return slices } /** * Segments the NoteArray into smaller ones at times where no note occurs * for a specified amount of time. * This method is useful for segmenting a recording session into separate * songs, riffs, licks, ... * * @param {number} gapDuration duration of seconds for a gap to be used as * segmenting time * @param {'start-start'|'end-start'} mode gaps can either be considered as * the maximum time between two note's starts or the end of the first * and the start of the second note * @returns {Note[][]} segments */ segmentAtGaps (gapDuration, mode) { if (this._notes.length < 2) { return [this._notes] } if (mode === 'start-start') { const notes = this.clone().sortByTime().getNotes() const cuts = [] for (let index = 1; index < notes.length; index++) { if (notes[index].start - notes[index - 1].start >= gapDuration) { cuts.push(notes[index].start) } } return this.sliceAtTimes(cuts, 'start') } else { // Get blocks of occupied time in the NoteArray's duration const occupiedTimes = [] // TODO: can probably be made faster in the future for (const note of this._notes) { const { start, end } = note // Check for collision const collisions = [] for (let index = 0; index < occupiedTimes.length; index++) { const [s, e] = occupiedTimes[index] if ( (s >= start && s <= end) || (e >= start && e <= end) ) { occupiedTimes.splice(index, 1) collisions.push([s, e]) } } if (collisions.length === 0) { // Just add note time span occupiedTimes.push([start, end]) } else { // Merge const newStart = Math.min(start, ...collisions.map(d => d[0])) const newEnd = Math.max(end, ...collisions.map(d => d[1])) occupiedTimes.push([newStart, newEnd]) } } // Gaps are just between two following blocks of occupied time if (occupiedTimes.length === 1) { // One block, so no gaps return [this._notes] } const cuts = [] for (let index = 1; index < occupiedTimes.length; index++) { const currentStart = occupiedTimes[index][0] const lastEnd = occupiedTimes[index - 1][1] if (currentStart - lastEnd >= gapDuration) { cuts.push(currentStart) } } return this.sliceAtTimes(cuts, 'start') } } /** * Segments the NoteArray into Arrays of Notes at given indices * * @param {number[]} indices indices * @returns {Note[][]} segments * @example <caption>Get notes in partions of 4</caption> * const noteGroups = myNoteArray.segmentAtIndices([4, 8, 12, 16, 20]); * // noteGroups = [ * // Array(4), * // Array(4), * // Array(4), * // ] */ segmentAtIndices (indices) { const segments = [] let lastIndex = 0 for (const index of indices) { segments.push(this._notes.slice(lastIndex, index)) lastIndex = index } return segments } /** * Filters the NoteArray like you would filter via Array.filter(). * * @param {Function} filterFunction filter function, same signature as * Array.filter() * @returns {NoteArray} itself * @example * // Only keep notes longer than 1 second * const filtered = noteArray.filter(note=>note.getDuration()>1); */ filter (filterFunction) { this._notes = this._notes.filter((element, index, array) => filterFunction(element, index, array)) return this } /** * Filters by pitch, keeping only pitches specified in <pitches> * * @param {number[]|Set<number>} pitches array or Set of pitches to keep * @returns {NoteArray} itself */ filterPitches (pitches) { if (!(pitches instanceof Set)) { pitches = new Set(pitches) } this._notes = this._notes.filter(n => pitches.has(n.pitch)) return this } /** * Transposes each note by <steps> semitones, will clip pitches to [0, 127] * * @param {number} steps number of semitones to transpose, can be negative * @returns {NoteArray} itself */ transpose (steps) { this._notes = this._notes.map(n => Note.from({ ...n, pitch: clipValue(n.pitch + steps, 0, 127) })) return this } /** * Will set the octave of all notes to -1. * This might cause two notes to exist at the same time and pitch! * * @returns {NoteArray} itself */ removeOctaves () { this._notes = this._notes.map(note => Note.from({ ...note, pitch: note.pitch % 12 })) return this } /** * Reverses the note array, such that it can be played backwards. * * @returns {NoteArray} itself */ reverse () { // Update note start and end times const duration = this.getDuration() this._notes = this._notes.map(n => { const newNote = n.clone() newNote.start = duration - n.end newNote.end = newNote.start + n.getDuration() return newNote }) // Sort by time this.sortByTime() return this } /** * Returns true if this NoteArray and otherNoteArray have equal attributes. * * @param {NoteArray} otherNoteArray another NoteArray * @returns {boolean} true if equal */ equals (otherNoteArray) { if (!(otherNoteArray instanceof NoteArray)) { return false } const notes = otherNoteArray.getNotes() if (this._notes.length !== notes.length) { return false } for (const [index, note] of notes.entries()) { if (!this._notes[index].equals(note)) { return false } } return true } /** * Deep clone, all contained notes are cloned as well. * * @returns {NoteArray} clone */ clone () { return new NoteArray(this._notes) } } export default NoteArray